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.
data/bin/gsc CHANGED
@@ -3,7 +3,7 @@
3
3
 
4
4
  # ==============================================================================
5
5
  # Google Search Console & Indexing API CLI Tool (Pure Ruby - Zero Gem Dependencies)
6
- # Standalone Single-File Distribution (Built from lib/gsc v2.0.1)
6
+ # Standalone Single-File Distribution (Built from lib/gsc v2.1.0)
7
7
  # ==============================================================================
8
8
 
9
9
  require 'net/http'
@@ -17,13 +17,19 @@ require 'date'
17
17
  require 'fileutils'
18
18
  require 'zlib'
19
19
  require 'stringio'
20
+ require 'csv'
21
+ require 'set'
20
22
 
21
- # --- version.rb ---
22
23
  module GSC
23
- VERSION = '2.0.1'
24
24
  end
25
25
 
26
- # --- color.rb ---
26
+ # --- Begin version.rb ---
27
+ module GSC
28
+ VERSION = '2.1.0'
29
+ end
30
+ # --- End version.rb ---
31
+
32
+ # --- Begin color.rb ---
27
33
  module GSC
28
34
  module Color
29
35
  RESET = "\e[0m"
@@ -44,17 +50,23 @@ module GSC
44
50
  end
45
51
  end
46
52
  end
53
+ # --- End color.rb ---
47
54
 
48
- # --- config.rb ---
49
- require 'fileutils'
50
- require 'json'
51
- require 'time'
52
-
55
+ # --- Begin config.rb ---
53
56
  module GSC
54
57
  class Config
55
58
  CONFIG_DIR = File.expand_path('~/.config/gsc')
56
59
  CONFIG_FILE = File.join(CONFIG_DIR, 'config.json')
57
60
 
61
+ def self.get(key)
62
+ load[key.to_s]
63
+ end
64
+
65
+ def self.set(key, value)
66
+ save(key.to_s => value)
67
+ value
68
+ end
69
+
58
70
  def self.load
59
71
  return {} unless File.exist?(CONFIG_FILE)
60
72
  JSON.parse(File.read(CONFIG_FILE))
@@ -221,8 +233,9 @@ end
221
233
 
222
234
  end
223
235
  end
236
+ # --- End config.rb ---
224
237
 
225
- # --- auth.rb ---
238
+ # --- Begin auth.rb ---
226
239
  module GSC
227
240
  class Auth
228
241
  OAUTH_TOKEN_URI = URI('https://oauth2.googleapis.com/token')
@@ -313,14 +326,9 @@ module GSC
313
326
  end
314
327
  end
315
328
  end
329
+ # --- End auth.rb ---
316
330
 
317
- # --- client.rb ---
318
- require 'net/http'
319
- require 'uri'
320
- require 'json'
321
- require 'zlib'
322
- require 'stringio'
323
-
331
+ # --- Begin client.rb ---
324
332
  module GSC
325
333
  class Client
326
334
  def initialize(token:)
@@ -392,8 +400,9 @@ module GSC
392
400
  end
393
401
  end
394
402
  end
403
+ # --- End client.rb ---
395
404
 
396
- # --- api.rb ---
405
+ # --- Begin api.rb ---
397
406
  module GSC
398
407
  class API
399
408
  def initialize(client)
@@ -692,13 +701,9 @@ end
692
701
  end
693
702
  end
694
703
  end
704
+ # --- End api.rb ---
695
705
 
696
- # --- sitemap_loader.rb ---
697
- require 'net/http'
698
- require 'uri'
699
- require 'zlib'
700
- require 'stringio'
701
-
706
+ # --- Begin sitemap_loader.rb ---
702
707
  module GSC
703
708
  class SitemapLoader
704
709
  def self.fetch_content(path_or_url)
@@ -783,8 +788,9 @@ module GSC
783
788
  end
784
789
  end
785
790
  end
791
+ # --- End sitemap_loader.rb ---
786
792
 
787
- # --- google_trends.rb ---
793
+ # --- Begin google_trends.rb ---
788
794
  module GSC
789
795
  class GoogleTrends
790
796
  TRENDS_HOST = 'trends.google.com'
@@ -970,8 +976,9 @@ class GoogleTrends
970
976
  end
971
977
  end
972
978
  end
979
+ # --- End google_trends.rb ---
973
980
 
974
- # --- keyword_planner.rb ---
981
+ # --- Begin keyword_planner.rb ---
975
982
  module GSC
976
983
  class KeywordPlanner
977
984
  SUGGEST_HOST = 'suggestqueries.google.com'
@@ -1226,8 +1233,9 @@ end
1226
1233
  end
1227
1234
  end
1228
1235
  end
1236
+ # --- End keyword_planner.rb ---
1229
1237
 
1230
- # --- keywords_everywhere.rb ---
1238
+ # --- Begin keywords_everywhere.rb ---
1231
1239
  module GSC
1232
1240
  class KeywordsEverywhere
1233
1241
  API_HOST = "api.keywordseverywhere.com"
@@ -1370,8 +1378,9 @@ class KeywordsEverywhere
1370
1378
  end
1371
1379
  end
1372
1380
  end
1381
+ # --- End keywords_everywhere.rb ---
1373
1382
 
1374
- # --- prompts.rb ---
1383
+ # --- Begin prompts.rb ---
1375
1384
  module GSC
1376
1385
  class Prompts
1377
1386
  PLAYBOOKS = [
@@ -1655,15 +1664,9 @@ module GSC
1655
1664
  end
1656
1665
  end
1657
1666
  end
1667
+ # --- End prompts.rb ---
1658
1668
 
1659
- # --- page_analyzer.rb ---
1660
- require 'net/http'
1661
- require 'uri'
1662
- require 'zlib'
1663
- require 'stringio'
1664
- require 'json'
1665
- require 'time'
1666
-
1669
+ # --- Begin page_analyzer.rb ---
1667
1670
  module GSC
1668
1671
  class PageAnalyzer
1669
1672
  attr_reader :url, :html, :http_status, :response_time_ms, :headers, :error
@@ -2086,14 +2089,9 @@ module GSC
2086
2089
  end
2087
2090
  end
2088
2091
  end
2092
+ # --- End page_analyzer.rb ---
2089
2093
 
2090
- # --- site_crawler.rb ---
2091
- # encoding: utf-8
2092
- require 'net/http'
2093
- require 'uri'
2094
- require 'json'
2095
- require 'fileutils'
2096
-
2094
+ # --- Begin site_crawler.rb ---
2097
2095
  module GSC
2098
2096
  class SiteCrawler
2099
2097
  attr_reader :target, :options, :results, :broken_links, :missing_alts, :heading_issues, :title_issues, :canonical_issues
@@ -2159,195 +2157,1932 @@ module GSC
2159
2157
  md << "---"
2160
2158
  md << ""
2161
2159
 
2162
- # 2. Broken Links
2163
- md << "## 🚨 2. Broken Links & Dead Anchors (#{summary[:broken_links_count]} Found)"
2164
- md << ""
2165
- if @broken_links.empty?
2166
- md << "✅ **No broken links detected across all audited pages!**"
2167
- else
2168
- md << "The following links returned HTTP errors (404 Not Found, 500 Server Error, or Timeout) and should be updated or removed:"
2169
- md << ""
2170
- md << "| Source Page URL | Target Broken URL | Anchor Text | HTTP Status |"
2171
- md << "| :--- | :--- | :--- | :---: |"
2172
- @broken_links.each do |b|
2173
- md << "| `#{b[:source_page]}` | `#{b[:href]}` | #{b[:anchor].empty? ? '*(Empty)*' : b[:anchor]} | **#{b[:status]}** |"
2160
+ # 2. Broken Links
2161
+ md << "## 🚨 2. Broken Links & Dead Anchors (#{summary[:broken_links_count]} Found)"
2162
+ md << ""
2163
+ if @broken_links.empty?
2164
+ md << "✅ **No broken links detected across all audited pages!**"
2165
+ else
2166
+ md << "The following links returned HTTP errors (404 Not Found, 500 Server Error, or Timeout) and should be updated or removed:"
2167
+ md << ""
2168
+ md << "| Source Page URL | Target Broken URL | Anchor Text | HTTP Status |"
2169
+ md << "| :--- | :--- | :--- | :---: |"
2170
+ @broken_links.each do |b|
2171
+ md << "| `#{b[:source_page]}` | `#{b[:href]}` | #{b[:anchor].empty? ? '*(Empty)*' : b[:anchor]} | **#{b[:status]}** |"
2172
+ end
2173
+ end
2174
+ md << ""
2175
+ md << "---"
2176
+ md << ""
2177
+
2178
+ # 3. Image Alt Tag Fixes
2179
+ md << "## 🖼️ 3. Images Missing Alt Attributes (#{summary[:missing_alts_count]} Found)"
2180
+ md << ""
2181
+ if @missing_alts.empty?
2182
+ md << "✅ **All images on audited pages have descriptive alt text!**"
2183
+ else
2184
+ md << "Search engines and screen readers rely on descriptive `alt` attributes to index visual content:"
2185
+ md << ""
2186
+ md << "| Page URL | Image Source URL | Recommended Fix |"
2187
+ md << "| :--- | :--- | :--- |"
2188
+ @missing_alts.each do |img|
2189
+ md << "| `#{img[:page_url]}` | `#{img[:src]}` | Add descriptive keywords to `alt=\"...\"` |"
2190
+ end
2191
+ end
2192
+ md << ""
2193
+ md << "---"
2194
+ md << ""
2195
+
2196
+ # 4. Heading Hierarchy Flaws
2197
+ md << "## 📑 4. Heading Hierarchy & H1 Flaws (#{summary[:heading_issues_count]} Found)"
2198
+ md << ""
2199
+ if @heading_issues.empty?
2200
+ md << "✅ **All pages have exactly one <h1> and clean structure!**"
2201
+ else
2202
+ md << "| Page URL | Issue Details | Recommended Action |"
2203
+ md << "| :--- | :--- | :--- |"
2204
+ @heading_issues.each do |h|
2205
+ md << "| `#{h[:page_url]}` | #{h[:issue]} | Ensure exactly one <h1> matching primary search query |"
2206
+ end
2207
+ end
2208
+ md << ""
2209
+ md << "---"
2210
+ md << ""
2211
+
2212
+ # 5. Title & Meta Description Flaws
2213
+ md << "## 🏷️ 5. Title & Meta Description Optimizations (#{summary[:title_meta_issues_count]} Found)"
2214
+ md << ""
2215
+ if @title_issues.empty?
2216
+ md << "✅ **All titles and meta descriptions meet 30–60 char and 70–155 char standards!**"
2217
+ else
2218
+ md << "| Page URL | Current Title (Chars) | Current Meta (Chars) | Flaw Detected |"
2219
+ md << "| :--- | :--- | :--- | :--- |"
2220
+ @title_issues.each do |t|
2221
+ md << "| `#{t[:page_url]}` | #{t[:title]} (#{t[:title_chars]}c) | #{t[:meta]} (#{t[:meta_chars]}c) | #{t[:flaw]} |"
2222
+ end
2223
+ end
2224
+ md << ""
2225
+ md << "---"
2226
+ md << ""
2227
+
2228
+ # 6. Prioritized AI Action Sprint
2229
+ md << "## 🤖 6. AI Agent Automated Fix Sprint"
2230
+ md << ""
2231
+ md << "Use these instructions to locate template files and apply fixes:"
2232
+ md << ""
2233
+ md << "1. **P0: Fix Broken Links**: Locate `<a href=\"...\">` tags pointing to dead URLs identified in Section 2."
2234
+ md << "2. **P0: Single <h1> Enforcement**: Ensure all template layouts have exactly one `<h1>`."
2235
+ md << "3. **P1: Image Alt Tag Insertion**: Add descriptive `alt` attributes to all images in Section 3."
2236
+ md << "4. **P1: Title Truncation Fix**: Keep `<title>` under 60 characters and `<meta name=\"description\">` under 155 characters."
2237
+ md << "5. **P2: Googlebot Re-Index**: Ping Google's Indexing API for all updated URLs via `gsc index <url>`."
2238
+ md << ""
2239
+ md << "---\n*Report generated by `gsc site-audit` (On-Page & Off-Page SEO Engine)*"
2240
+
2241
+ File.write(filepath, md.join("\n"), encoding: 'UTF-8')
2242
+ filepath
2243
+ end
2244
+
2245
+ def aggregate_summary
2246
+ critical_errors = @broken_links.size + @results.count { |r| r[:indexability][:noindex] }
2247
+ total_issues = critical_errors + @missing_alts.size + @heading_issues.size + @title_issues.size + @canonical_issues.size
2248
+
2249
+ {
2250
+ total_pages: @results.size,
2251
+ total_issues: total_issues,
2252
+ critical_errors_count: critical_errors,
2253
+ broken_links_count: @broken_links.size,
2254
+ missing_alts_count: @missing_alts.size,
2255
+ heading_issues_count: @heading_issues.size,
2256
+ title_meta_issues_count: @title_issues.size,
2257
+ canonical_issues_count: @canonical_issues.size
2258
+ }
2259
+ end
2260
+
2261
+ private
2262
+
2263
+ def discover_urls(target)
2264
+ if target.end_with?('.xml') || target.include?('sitemap')
2265
+ SitemapLoader.load_urls(target)
2266
+ elsif File.file?(target)
2267
+ [target]
2268
+ else
2269
+ normalized = target.start_with?('http') ? target : "https://#{target}"
2270
+ sitemap_url = "#{normalized.sub(%r{/+$}, '')}/sitemap.xml"
2271
+ urls = SitemapLoader.load_urls(sitemap_url)
2272
+ urls.empty? ? [normalized] : urls
2273
+ end
2274
+ rescue StandardError
2275
+ [target.start_with?('http') ? target : "https://#{target}"]
2276
+ end
2277
+
2278
+ def categorize_page_issues(data)
2279
+ page_url = data[:url]
2280
+
2281
+ # Broken links
2282
+ if data.dig(:links, :verification)
2283
+ data[:links][:verification].each do |link|
2284
+ if !link[:ok]
2285
+ @broken_links << {
2286
+ source_page: page_url,
2287
+ href: link[:href],
2288
+ anchor: link[:anchor],
2289
+ status: link[:status] || 'Error'
2290
+ }
2291
+ end
2292
+ end
2293
+ end
2294
+
2295
+ # Missing alts
2296
+ if data.dig(:images, :missing_alt_images)
2297
+ data[:images][:missing_alt_images].each do |img|
2298
+ @missing_alts << {
2299
+ page_url: page_url,
2300
+ src: img[:src]
2301
+ }
2302
+ end
2303
+ end
2304
+
2305
+ # Headings
2306
+ h1_count = data.dig(:headings, :h1_count) || 0
2307
+ if h1_count == 0
2308
+ @heading_issues << { page_url: page_url, issue: "Missing <h1> tag (0 found)" }
2309
+ elsif h1_count > 1
2310
+ @heading_issues << { page_url: page_url, issue: "Multiple <h1> tags (#{h1_count} found)" }
2311
+ end
2312
+
2313
+ # Title & Meta
2314
+ title_chars = data.dig(:title, :length) || 0
2315
+ meta_chars = data.dig(:meta_description, :length) || 0
2316
+ title_text = data.dig(:title, :text) || ''
2317
+ meta_text = data.dig(:meta_description, :text) || ''
2318
+
2319
+ flaws = []
2320
+ flaws << "Title > 60 chars" if title_chars > 60
2321
+ flaws << "Title < 30 chars" if title_chars > 0 && title_chars < 30
2322
+ flaws << "Missing Title" if title_chars == 0
2323
+ flaws << "Meta > 155 chars" if meta_chars > 155
2324
+ flaws << "Missing Meta" if meta_chars == 0
2325
+
2326
+ if !flaws.empty?
2327
+ @title_issues << {
2328
+ page_url: page_url,
2329
+ title: title_text[0..40],
2330
+ title_chars: title_chars,
2331
+ meta: meta_text[0..40],
2332
+ meta_chars: meta_chars,
2333
+ flaw: flaws.join(', ')
2334
+ }
2335
+ end
2336
+
2337
+ # Canonical
2338
+ if data.dig(:canonical, :url) && !data.dig(:canonical, :self_referencing)
2339
+ @canonical_issues << {
2340
+ page_url: page_url,
2341
+ canonical_url: data[:canonical][:url]
2342
+ }
2343
+ end
2344
+ end
2345
+ end
2346
+ end
2347
+ # --- End site_crawler.rb ---
2348
+
2349
+ # --- Begin google_suggest.rb ---
2350
+ module GSC
2351
+ class GoogleSuggest
2352
+ SUGGEST_URL = 'https://suggestqueries.google.com/complete/search'
2353
+
2354
+ attr_reader :query, :options
2355
+
2356
+ def initialize(query, options = {})
2357
+ @query = query.to_s.strip
2358
+ @options = options
2359
+ end
2360
+
2361
+ def fetch(alphabet: false, questions: false)
2362
+ if questions
2363
+ fetch_questions
2364
+ elsif alphabet
2365
+ fetch_alphabet_soup
2366
+ else
2367
+ fetch_single(@query)
2368
+ end
2369
+ end
2370
+
2371
+ def fetch_single(search_term)
2372
+ uri = URI("#{SUGGEST_URL}?client=chrome&q=#{URI.encode_www_form_component(search_term)}")
2373
+ req = Net::HTTP::Get.new(uri)
2374
+ req['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36'
2375
+ req['Accept'] = 'application/json, text/javascript, */*; q=0.01'
2376
+
2377
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 10) do |http|
2378
+ http.request(req)
2379
+ end
2380
+
2381
+ return [] unless res.code == '200'
2382
+
2383
+ parsed = JSON.parse(res.body.force_encoding('UTF-8'))
2384
+ terms = parsed[1] || []
2385
+ types = parsed[4] ? parsed[4]['google:suggesttype'] || [] : []
2386
+
2387
+ terms.map.with_index do |term, idx|
2388
+ {
2389
+ term: term,
2390
+ type: types[idx] || 'QUERY',
2391
+ root: search_term
2392
+ }
2393
+ end
2394
+ rescue StandardError => e
2395
+ []
2396
+ end
2397
+
2398
+ def fetch_alphabet_soup
2399
+ results = {}
2400
+ base = @query.strip
2401
+
2402
+ # Root query first
2403
+ results['root'] = fetch_single(base)
2404
+
2405
+ # A-Z permutations
2406
+ ('a'..'z').each do |letter|
2407
+ term = "#{base} #{letter}"
2408
+ items = fetch_single(term)
2409
+ results[letter] = items unless items.empty?
2410
+ sleep(0.05) # Polite throttle
2411
+ end
2412
+
2413
+ # 0-9 permutations if requested
2414
+ if @options[:numbers]
2415
+ ('0'..'9').each do |num|
2416
+ term = "#{base} #{num}"
2417
+ items = fetch_single(term)
2418
+ results[num] = items unless items.empty?
2419
+ sleep(0.05)
2420
+ end
2421
+ end
2422
+
2423
+ results
2424
+ end
2425
+
2426
+ def fetch_questions
2427
+ prefixes = [
2428
+ 'how to',
2429
+ 'how do',
2430
+ 'why do',
2431
+ 'why does',
2432
+ 'what is',
2433
+ 'what are',
2434
+ 'can you',
2435
+ 'best',
2436
+ 'where to',
2437
+ 'which'
2438
+ ]
2439
+
2440
+ results = {}
2441
+ prefixes.each do |pfx|
2442
+ term = "#{pfx} #{@query}"
2443
+ items = fetch_single(term)
2444
+ results[pfx] = items unless items.empty?
2445
+ sleep(0.05)
2446
+ end
2447
+
2448
+ results
2449
+ end
2450
+ end
2451
+ end
2452
+ # --- End google_suggest.rb ---
2453
+
2454
+ # --- Begin open_page_rank.rb ---
2455
+ module GSC
2456
+ class OpenPageRank
2457
+ API_URL = 'https://openpagerank.com/api/v1.0/getPageRank'
2458
+
2459
+ attr_reader :api_key
2460
+
2461
+ def initialize(api_key = nil)
2462
+ @api_key = api_key || ENV['OPENPAGERANK_API_KEY'] || GSC::Config.get('opr_api_key')
2463
+ end
2464
+
2465
+ def configured?
2466
+ !@api_key.nil? && !@api_key.strip.empty?
2467
+ end
2468
+
2469
+ def check_domains(domains)
2470
+ domains = Array(domains).map { |d| clean_domain(d) }.reject(&:empty?).uniq
2471
+ return { error: 'No valid domains provided' } if domains.empty?
2472
+ 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?
2473
+
2474
+ # Construct query params: domains[0]=a.com&domains[1]=b.com
2475
+ params = domains.map.with_index { |d, idx| "domains%5B#{idx}%5D=#{URI.encode_www_form_component(d)}" }.join('&')
2476
+ uri = URI("#{API_URL}?#{params}")
2477
+
2478
+ req = Net::HTTP::Get.new(uri)
2479
+ req['API-OPR'] = @api_key
2480
+ req['User-Agent'] = 'gsc-cli/2.1'
2481
+
2482
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 5, read_timeout: 15) do |http|
2483
+ http.request(req)
2484
+ end
2485
+
2486
+ if res.code == '200'
2487
+ data = JSON.parse(res.body)
2488
+ records = (data['response'] || []).map do |r|
2489
+ {
2490
+ domain: r['domain'],
2491
+ page_rank_decimal: r['page_rank_decimal'] || 0.0,
2492
+ page_rank_integer: r['page_rank_integer'] || 0,
2493
+ rank: r['rank'],
2494
+ status_code: r['status_code']
2495
+ }
2496
+ end
2497
+ {
2498
+ status: 'success',
2499
+ status_code: data['status_code'],
2500
+ records: records
2501
+ }
2502
+ else
2503
+ {
2504
+ status: 'error',
2505
+ code: res.code.to_i,
2506
+ message: res.body
2507
+ }
2508
+ end
2509
+ rescue StandardError => e
2510
+ { status: 'error', message: e.message }
2511
+ end
2512
+
2513
+ def clean_domain(input)
2514
+ d = input.to_s.strip.downcase
2515
+ d = d.sub(%r{^https?://}, '').sub(%r{/.*$}, '').sub(/^www\./, '')
2516
+ d
2517
+ end
2518
+ end
2519
+ end
2520
+ # --- End open_page_rank.rb ---
2521
+
2522
+ # --- Begin page_speed.rb ---
2523
+ module GSC
2524
+ class PageSpeed
2525
+ API_URL = 'https://www.googleapis.com/pagespeedonline/v5/runPagespeed'
2526
+
2527
+ attr_reader :url, :strategy, :api_key
2528
+
2529
+ def initialize(url, strategy: 'mobile', api_key: nil)
2530
+ @url = url.to_s.strip
2531
+ @strategy = strategy.to_s.downcase == 'desktop' ? 'desktop' : 'mobile'
2532
+ @api_key = api_key || ENV['PAGESPEED_API_KEY'] || GSC::Config.get('pagespeed_api_key')
2533
+ end
2534
+
2535
+ def run
2536
+ query_params = [
2537
+ "url=#{URI.encode_www_form_component(@url)}",
2538
+ "strategy=#{@strategy}",
2539
+ "category=performance",
2540
+ "category=seo"
2541
+ ]
2542
+ query_params << "key=#{URI.encode_www_form_component(@api_key)}" if @api_key && !@api_key.empty?
2543
+
2544
+ uri = URI("#{API_URL}?#{query_params.join('&')}")
2545
+ req = Net::HTTP::Get.new(uri)
2546
+ req['User-Agent'] = 'gsc-cli/2.1'
2547
+
2548
+ res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, open_timeout: 10, read_timeout: 30) do |http|
2549
+ http.request(req)
2550
+ end
2551
+
2552
+ if res.code == '200'
2553
+ parse_response(JSON.parse(res.body))
2554
+ else
2555
+ {
2556
+ error: true,
2557
+ status_code: res.code.to_i,
2558
+ message: res.body
2559
+ }
2560
+ end
2561
+ rescue StandardError => e
2562
+ { error: true, message: e.message }
2563
+ end
2564
+
2565
+ private
2566
+
2567
+ def parse_response(data)
2568
+ lhr = data['lighthouseResult'] || {}
2569
+ categories = lhr['categories'] || {}
2570
+ perf_score = ((categories.dig('performance', 'score') || 0) * 100).round
2571
+ seo_score = ((categories.dig('seo', 'score') || 0) * 100).round
2572
+
2573
+ audits = lhr['audits'] || {}
2574
+
2575
+ # Core Web Vitals
2576
+ fcp = audits.dig('first-contentful-paint', 'displayValue')
2577
+ lcp = audits.dig('largest-contentful-paint', 'displayValue')
2578
+ cls = audits.dig('cumulative-layout-shift', 'displayValue')
2579
+ tbt = audits.dig('total-blocking-time', 'displayValue')
2580
+ si = audits.dig('speed-index', 'displayValue')
2581
+
2582
+ # CrUX Field Data (if available)
2583
+ crux_metrics = {}
2584
+ crux = data.dig('loadingExperience', 'metrics') || {}
2585
+ crux.each do |k, v|
2586
+ crux_metrics[k] = {
2587
+ percentile: v['percentile'],
2588
+ category: v['category']
2589
+ }
2590
+ end
2591
+
2592
+ # Opportunities
2593
+ opportunities = []
2594
+ audits.each do |k, v|
2595
+ next unless v['details'] && v['details']['type'] == 'opportunity'
2596
+ next unless v['numericValue'] && v['numericValue'] > 100
2597
+
2598
+ opportunities << {
2599
+ id: k,
2600
+ title: v['title'],
2601
+ savings_ms: v['numericValue'] ? v['numericValue'].round : 0,
2602
+ display: v['displayValue']
2603
+ }
2604
+ end
2605
+ opportunities.sort_by! { |o| -o[:savings_ms] }
2606
+
2607
+ {
2608
+ url: @url,
2609
+ strategy: @strategy,
2610
+ fetch_time: lhr['fetchTime'],
2611
+ performance_score: perf_score,
2612
+ seo_score: seo_score,
2613
+ metrics: {
2614
+ fcp: fcp,
2615
+ lcp: lcp,
2616
+ cls: cls,
2617
+ tbt: tbt,
2618
+ speed_index: si
2619
+ },
2620
+ field_data: crux_metrics,
2621
+ opportunities: opportunities.first(5)
2622
+ }
2623
+ end
2624
+ end
2625
+ end
2626
+ # --- End page_speed.rb ---
2627
+
2628
+ # --- Begin page_comparator.rb ---
2629
+ module GSC
2630
+ class PageComparator
2631
+ attr_reader :url1, :url2, :data1, :data2
2632
+
2633
+ def initialize(url1, url2)
2634
+ @url1 = url1
2635
+ @url2 = url2
2636
+ end
2637
+
2638
+ def compare
2639
+ pa1 = GSC::PageAnalyzer.new(@url1)
2640
+ pa2 = GSC::PageAnalyzer.new(@url2)
2641
+
2642
+ @data1 = pa1.fetch_and_analyze
2643
+ @data2 = pa2.fetch_and_analyze
2644
+
2645
+ diffs = {
2646
+ meta: compare_meta,
2647
+ headings: compare_headings,
2648
+ images: compare_images,
2649
+ links: compare_links,
2650
+ performance: compare_perf,
2651
+ structured_data: compare_schema
2652
+ }
2653
+
2654
+ {
2655
+ page1: { url: @url1, status: @data1[:http_status] },
2656
+ page2: { url: @url2, status: @data2[:http_status] },
2657
+ comparison: diffs
2658
+ }
2659
+ end
2660
+
2661
+ private
2662
+
2663
+ def compare_meta
2664
+ t1 = @data1.dig(:title, :text) || ''
2665
+ t2 = @data2.dig(:title, :text) || ''
2666
+ m1 = @data1.dig(:meta_description, :text) || ''
2667
+ m2 = @data2.dig(:meta_description, :text) || ''
2668
+
2669
+ {
2670
+ title: {
2671
+ page1: { text: t1, length: t1.length, optimal: t1.length.between?(30, 60) },
2672
+ page2: { text: t2, length: t2.length, optimal: t2.length.between?(30, 60) }
2673
+ },
2674
+ meta_description: {
2675
+ page1: { text: m1, length: m1.length, optimal: m1.length.between?(70, 155) },
2676
+ page2: { text: m2, length: m2.length, optimal: m2.length.between?(70, 155) }
2677
+ }
2678
+ }
2679
+ end
2680
+
2681
+ def compare_headings
2682
+ h1_1 = @data1.dig(:headings, :h1) || []
2683
+ h1_2 = @data2.dig(:headings, :h1) || []
2684
+ h2_1 = @data1.dig(:headings, :h2) || []
2685
+ h2_2 = @data2.dig(:headings, :h2) || []
2686
+
2687
+ {
2688
+ h1_count: { page1: h1_1.length, page2: h1_2.length },
2689
+ h1_text: { page1: h1_1.first, page2: h1_2.first },
2690
+ h2_count: { page1: h2_1.length, page2: h2_2.length }
2691
+ }
2692
+ end
2693
+
2694
+ def compare_images
2695
+ img1 = @data1[:images] || {}
2696
+ img2 = @data2[:images] || {}
2697
+
2698
+ {
2699
+ total_images: { page1: img1[:total] || 0, page2: img2[:total] || 0 },
2700
+ missing_alt: { page1: img1[:missing_alt] || 0, page2: img2[:missing_alt] || 0 }
2701
+ }
2702
+ end
2703
+
2704
+ def compare_links
2705
+ l1 = @data1[:links] || {}
2706
+ l2 = @data2[:links] || {}
2707
+
2708
+ {
2709
+ internal: { page1: l1[:internal_count] || 0, page2: l2[:internal_count] || 0 },
2710
+ external: { page1: l1[:external_count] || 0, page2: l2[:external_count] || 0 }
2711
+ }
2712
+ end
2713
+
2714
+ def compare_perf
2715
+ {
2716
+ response_time_ms: { page1: @data1[:response_time_ms], page2: @data2[:response_time_ms] }
2717
+ }
2718
+ end
2719
+
2720
+ def compare_schema
2721
+ s1 = @data1.dig(:structured_data, :schemas) || []
2722
+ s2 = @data2.dig(:structured_data, :schemas) || []
2723
+
2724
+ types1 = s1.map { |s| s['@type'] }.compact
2725
+ types2 = s2.map { |s| s['@type'] }.compact
2726
+
2727
+ {
2728
+ schema_count: { page1: s1.length, page2: s2.length },
2729
+ schema_types: { page1: types1, page2: types2 }
2730
+ }
2731
+ end
2732
+ end
2733
+ end
2734
+ # --- End page_comparator.rb ---
2735
+
2736
+ # --- Begin content_gap.rb ---
2737
+ module GSC
2738
+ class ContentGap
2739
+ STOP_WORDS = Set.new(%w[
2740
+ a about above after again against all am an and any are aren't as at be because been before being below
2741
+ between both but by can't cannot could couldn't did didn't do does doesn't doing don't down during each
2742
+ few for from further had hadn't has hasn't have haven't having he he'd he'll he's her here here's hers
2743
+ herself him himself his how how's i i'd i'll i'm i've if in into is isn't it it's its itself let's me
2744
+ more most mustn't my myself no nor not of off on once only or other ought our ours ourselves out over own
2745
+ same shan't she she'd she'll she's should shouldn't so some such than that that's the their theirs them
2746
+ themselves then there there's these they they'd they'll they're they've this those through to too under until
2747
+ up very was wasn't we we'd we'll we're we've were weren't what what's when when's where where's which while
2748
+ who who's whom why why's with won't would wouldn't you you'd you'll you're you've your yours yourself yourselves
2749
+ ])
2750
+
2751
+ attr_reader :url1, :url2
2752
+
2753
+ def initialize(url1, url2)
2754
+ @url1 = url1
2755
+ @url2 = url2
2756
+ end
2757
+
2758
+ def analyze
2759
+ pa1 = GSC::PageAnalyzer.new(@url1)
2760
+ pa2 = GSC::PageAnalyzer.new(@url2)
2761
+
2762
+ data1 = pa1.fetch_and_analyze
2763
+ data2 = pa2.fetch_and_analyze
2764
+
2765
+ text1 = extract_clean_text(pa1.html || '')
2766
+ text2 = extract_clean_text(pa2.html || '')
2767
+
2768
+ tokens1 = tokenize(text1)
2769
+ tokens2 = tokenize(text2)
2770
+
2771
+ unigrams1 = ngrams(tokens1, 1)
2772
+ unigrams2 = ngrams(tokens2, 1)
2773
+
2774
+ bigrams1 = ngrams(tokens1, 2)
2775
+ bigrams2 = ngrams(tokens2, 2)
2776
+
2777
+ trigrams1 = ngrams(tokens1, 3)
2778
+ trigrams2 = ngrams(tokens2, 3)
2779
+
2780
+ # Competitor terms that occur at least 2 times, but occur 0 times in page1
2781
+ missing_unigrams = term_gap(unigrams1, unigrams2, min_count: 2)
2782
+ missing_bigrams = term_gap(bigrams1, bigrams2, min_count: 2)
2783
+ missing_trigrams = term_gap(trigrams1, trigrams2, min_count: 2)
2784
+
2785
+ # Heading gaps (H2/H3 in page2 that have no match in page1)
2786
+ h1 = (data1.dig(:headings, :h2) || []) + (data1.dig(:headings, :h3) || [])
2787
+ h2 = (data2.dig(:headings, :h2) || []) + (data2.dig(:headings, :h3) || [])
2788
+
2789
+ missing_headings = h2.reject do |heading|
2790
+ h1.any? { |my_h| my_h.downcase.include?(heading.downcase[0..15]) }
2791
+ end
2792
+
2793
+ {
2794
+ page1: { url: @url1, word_count: tokens1.length },
2795
+ page2: { url: @url2, word_count: tokens2.length },
2796
+ missing_unigrams: missing_unigrams.first(15),
2797
+ missing_bigrams: missing_bigrams.first(15),
2798
+ missing_trigrams: missing_trigrams.first(10),
2799
+ missing_headings: missing_headings.first(10)
2800
+ }
2801
+ end
2802
+
2803
+ private
2804
+
2805
+ def extract_clean_text(html)
2806
+ text = html.dup
2807
+ text.gsub!(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/i, ' ')
2808
+ text.gsub!(/<style\b[^<]*(?:(?!<\/style>)<[^<]*)*<\/style>/i, ' ')
2809
+ text.gsub!(/<nav\b[^<]*(?:(?!<\/nav>)<[^<]*)*<\/nav>/i, ' ')
2810
+ text.gsub!(/<footer\b[^<]*(?:(?!<\/footer>)<[^<]*)*<\/footer>/i, ' ')
2811
+ text.gsub!(/<[^>]+>/, ' ')
2812
+ text.gsub!(/&[a-z]+;/i, ' ')
2813
+ text.gsub!(/\s+/, ' ')
2814
+ text.strip
2815
+ end
2816
+
2817
+ def tokenize(text)
2818
+ text.downcase.scan(/[a-z0-9]+/).reject { |w| w.length < 3 || STOP_WORDS.include?(w) }
2819
+ end
2820
+
2821
+ def ngrams(tokens, n)
2822
+ counts = Hash.new(0)
2823
+ return counts if tokens.length < n
2824
+
2825
+ (0..(tokens.length - n)).each do |i|
2826
+ gram = tokens[i, n].join(' ')
2827
+ counts[gram] += 1
2828
+ end
2829
+ counts
2830
+ end
2831
+
2832
+ def term_gap(counts1, counts2, min_count: 2)
2833
+ gap = []
2834
+ counts2.each do |term, count2|
2835
+ count1 = counts1[term] || 0
2836
+ if count2 >= min_count && count1 == 0
2837
+ gap << { term: term, competitor_count: count2, your_count: count1 }
2838
+ end
2839
+ end
2840
+ gap.sort_by { |item| -item[:competitor_count] }
2841
+ end
2842
+ end
2843
+ end
2844
+ # --- End content_gap.rb ---
2845
+
2846
+ # --- Begin internal_links.rb ---
2847
+ module GSC
2848
+ class InternalLinks
2849
+ attr_reader :base_url, :pages, :graph, :orphans, :depths
2850
+
2851
+ def initialize(base_url, limit: 50)
2852
+ @base_url = base_url.to_s.strip
2853
+ @base_url = "https://#{@base_url}" unless @base_url =~ %r{^https?://}
2854
+ @base_uri = URI.parse(@base_url)
2855
+ @limit = limit
2856
+ @graph = Hash.new { |h, k| h[k] = Set.new } # target_url => Set of source_urls
2857
+ @out_links = Hash.new { |h, k| h[k] = Set.new } # source_url => Set of target_urls
2858
+ @all_discovered = Set.new
2859
+ end
2860
+
2861
+ def audit(sitemap_urls = nil)
2862
+ urls_to_crawl = if sitemap_urls && !sitemap_urls.empty?
2863
+ sitemap_urls.first(@limit)
2864
+ else
2865
+ discover_urls
2866
+ end
2867
+
2868
+ urls_to_crawl.each do |url|
2869
+ @all_discovered << normalize_url(url)
2870
+ end
2871
+
2872
+ # Crawl each URL and extract internal links
2873
+ urls_to_crawl.each do |url|
2874
+ pa = GSC::PageAnalyzer.new(url)
2875
+ pa.load_content! rescue next
2876
+ dom = pa.analyze_dom rescue next
2877
+
2878
+ norm_source = normalize_url(url)
2879
+ links = dom.dig(:links, :all) || []
2880
+
2881
+ links.each do |link_obj|
2882
+ href = link_obj[:href]
2883
+ target_url = resolve_internal_url(href)
2884
+ next unless target_url
2885
+
2886
+ norm_target = normalize_url(target_url)
2887
+ next if norm_target == norm_source
2888
+
2889
+ @graph[norm_target] << norm_source
2890
+ @out_links[norm_source] << norm_target
2891
+ end
2892
+ end
2893
+
2894
+ # Calculate Orphans (pages in sitemap/discovered with 0 incoming internal links)
2895
+ orphans = []
2896
+ weak_pages = [] # only 1 internal link
2897
+
2898
+ @all_discovered.each do |url|
2899
+ in_degree = @graph[url].size
2900
+ if in_degree == 0 && url != normalize_url(@base_url)
2901
+ orphans << url
2902
+ elsif in_degree == 1
2903
+ weak_pages << { url: url, source: @graph[url].first }
2904
+ end
2905
+ end
2906
+
2907
+ # Calculate click depths via BFS from root
2908
+ depths = calculate_depths(normalize_url(@base_url))
2909
+
2910
+ {
2911
+ base_url: @base_url,
2912
+ total_pages: @all_discovered.size,
2913
+ orphans: orphans,
2914
+ weak_pages: weak_pages,
2915
+ top_linked: top_linked_pages(10),
2916
+ depths: depths
2917
+ }
2918
+ end
2919
+
2920
+ private
2921
+
2922
+ def discover_urls
2923
+ loader = GSC::SitemapLoader.new(@base_url)
2924
+ urls = loader.load
2925
+ urls.empty? ? [@base_url] : urls.first(@limit)
2926
+ rescue StandardError
2927
+ [@base_url]
2928
+ end
2929
+
2930
+ def resolve_internal_url(href)
2931
+ return nil if href.nil? || href.strip.empty?
2932
+ return nil if href =~ /^(mailto|tel|javascript|#):/i
2933
+
2934
+ uri = URI.join(@base_url, href) rescue nil
2935
+ return nil unless uri && uri.scheme =~ /^https?$/i
2936
+ return nil unless uri.host.downcase == @base_uri.host.downcase
2937
+
2938
+ uri.fragment = nil
2939
+ uri.to_s
2940
+ end
2941
+
2942
+ def normalize_url(url)
2943
+ u = url.to_s.strip.sub(%r{/$}, '')
2944
+ u
2945
+ end
2946
+
2947
+ def calculate_depths(root_url)
2948
+ depths = { root_url => 0 }
2949
+ queue = [root_url]
2950
+
2951
+ until queue.empty?
2952
+ curr = queue.shift
2953
+ curr_depth = depths[curr]
2954
+
2955
+ (@out_links[curr] || []).each do |neighbor|
2956
+ next if depths.key?(neighbor)
2957
+
2958
+ depths[neighbor] = curr_depth + 1
2959
+ queue << neighbor
2960
+ end
2961
+ end
2962
+
2963
+ depths
2964
+ end
2965
+
2966
+ def top_linked_pages(limit)
2967
+ @graph.map do |url, sources|
2968
+ { url: url, incoming_count: sources.size }
2969
+ end.sort_by { |item| -item[:incoming_count] }.first(limit)
2970
+ end
2971
+ end
2972
+ end
2973
+ # --- End internal_links.rb ---
2974
+
2975
+ # --- Begin schema_validator.rb ---
2976
+ module GSC
2977
+ class SchemaValidator
2978
+ attr_reader :url, :schemas, :validation_results
2979
+
2980
+ def initialize(url)
2981
+ @url = url.to_s.strip
2982
+ end
2983
+
2984
+ def audit
2985
+ pa = GSC::PageAnalyzer.new(@url)
2986
+ data = pa.fetch_and_analyze
2987
+ @schemas = data.dig(:structured_data, :schemas) || []
2988
+
2989
+ results = []
2990
+ @schemas.each_with_index do |schema, idx|
2991
+ results << validate_single_schema(schema, idx)
2992
+ end
2993
+
2994
+ {
2995
+ url: @url,
2996
+ total_schemas: @schemas.length,
2997
+ schemas: results
2998
+ }
2999
+ end
3000
+
3001
+ def validate_single_schema(schema, idx)
3002
+ type = schema['@type'] || 'Unknown'
3003
+ errors = []
3004
+ warnings = []
3005
+
3006
+ case type
3007
+ when 'SoftwareApplication', 'WebApplication'
3008
+ errors << 'Missing "name"' unless schema['name']
3009
+ warnings << 'Missing "operatingSystem"' unless schema['operatingSystem']
3010
+ warnings << 'Missing "applicationCategory"' unless schema['applicationCategory']
3011
+ warnings << 'Missing "offers"' unless schema['offers']
3012
+ warnings << 'Missing "aggregateRating"' unless schema['aggregateRating']
3013
+
3014
+ when 'FAQPage'
3015
+ main_entity = schema['mainEntity']
3016
+ if !main_entity || !main_entity.is_a?(Array) || main_entity.empty?
3017
+ errors << 'FAQPage must contain a non-empty "mainEntity" array'
3018
+ else
3019
+ main_entity.each_with_index do |q, q_idx|
3020
+ errors << "Question ##{q_idx + 1} missing name" unless q['name']
3021
+ errors << "Question ##{q_idx + 1} missing acceptedAnswer" unless q['acceptedAnswer']
3022
+ end
3023
+ end
3024
+
3025
+ when 'Product'
3026
+ errors << 'Missing "name"' unless schema['name']
3027
+ warnings << 'Missing "image"' unless schema['image']
3028
+ warnings << 'Missing "offers"' unless schema['offers']
3029
+
3030
+ when 'Article', 'BlogPosting'
3031
+ errors << 'Missing "headline"' unless schema['headline']
3032
+ errors << 'Missing "author"' unless schema['author']
3033
+ warnings << 'Missing "datePublished"' unless schema['datePublished']
3034
+ warnings << 'Missing "image"' unless schema['image']
3035
+
3036
+ when 'Organization', 'LocalBusiness'
3037
+ errors << 'Missing "name"' unless schema['name']
3038
+ errors << 'Missing "url"' unless schema['url']
3039
+ warnings << 'Missing "logo"' unless schema['logo']
3040
+ end
3041
+
3042
+ {
3043
+ index: idx,
3044
+ type: type,
3045
+ valid: errors.empty?,
3046
+ errors: errors,
3047
+ warnings: warnings,
3048
+ raw: schema
3049
+ }
3050
+ end
3051
+
3052
+ def self.generate_template(type, params = {})
3053
+ case type.to_s.downcase
3054
+ when 'faq'
3055
+ {
3056
+ '@context' => 'https://schema.org',
3057
+ '@type' => 'FAQPage',
3058
+ 'mainEntity' => [
3059
+ {
3060
+ '@type' => 'Question',
3061
+ 'name' => params[:question] || 'What is PackingLog?',
3062
+ 'acceptedAnswer' => {
3063
+ '@type' => 'Answer',
3064
+ 'text' => params[:answer] || 'PackingLog is a free moving box and inventory management system.'
3065
+ }
3066
+ }
3067
+ ]
3068
+ }
3069
+ when 'software', 'app'
3070
+ {
3071
+ '@context' => 'https://schema.org',
3072
+ '@type' => 'SoftwareApplication',
3073
+ 'name' => params[:name] || 'PackingLog',
3074
+ 'applicationCategory' => params[:category] || 'UtilitiesApplication',
3075
+ 'operatingSystem' => 'Web, iOS, Android',
3076
+ 'offers' => {
3077
+ '@type' => 'Offer',
3078
+ 'price' => '0',
3079
+ 'priceCurrency' => 'USD'
3080
+ }
3081
+ }
3082
+ else
3083
+ {
3084
+ '@context' => 'https://schema.org',
3085
+ '@type' => 'Organization',
3086
+ 'name' => params[:name] || 'PackingLog',
3087
+ 'url' => params[:url] || 'https://packinglog.com'
3088
+ }
3089
+ end
3090
+ end
3091
+ end
3092
+ end
3093
+ # --- End schema_validator.rb ---
3094
+
3095
+ # --- Begin llms_generator.rb ---
3096
+ module GSC
3097
+ class LlmsGenerator
3098
+ attr_reader :base_url, :pages
3099
+
3100
+ def initialize(base_url)
3101
+ @base_url = base_url.to_s.strip
3102
+ @base_url = "https://#{@base_url}" unless @base_url =~ %r{^https?://}
3103
+ end
3104
+
3105
+ def generate_llms_txt(title: nil, summary: nil)
3106
+ loader = GSC::SitemapLoader.new(@base_url)
3107
+ urls = loader.load rescue [@base_url]
3108
+ urls = [@base_url] if urls.empty?
3109
+
3110
+ site_title = title || URI.parse(@base_url).host.sub(/^www\./, '').capitalize
3111
+ site_summary = summary || "Official documentation and product guides for #{site_title}."
3112
+
3113
+ out = []
3114
+ out << "# #{site_title}"
3115
+ out << ""
3116
+ out << "> #{site_summary}"
3117
+ out << ""
3118
+ out << "## Core Documentation"
3119
+ out << ""
3120
+
3121
+ # Sample first 15 key URLs and fetch metadata
3122
+ urls.first(15).each do |url|
3123
+ pa = GSC::PageAnalyzer.new(url)
3124
+ pa.load_content! rescue next
3125
+ dom = pa.analyze_dom rescue next
3126
+
3127
+ page_title = dom.dig(:title, :text) || url
3128
+ page_desc = dom.dig(:meta_description, :text) || "Documentation page."
3129
+
3130
+ out << "- [#{page_title}](#{url}): #{page_desc}"
3131
+ end
3132
+
3133
+ out << ""
3134
+ out << "## Optional"
3135
+ out << ""
3136
+ out << "- [Full Documentation](#{@base_url}/llms-full.txt): Complete consolidated knowledge base for LLM context ingestion."
3137
+ out << ""
3138
+
3139
+ out.join("\n")
3140
+ end
3141
+
3142
+ def audit_ai_readability(url)
3143
+ pa = GSC::PageAnalyzer.new(url)
3144
+ data = pa.fetch_and_analyze
3145
+
3146
+ # Check criteria:
3147
+ # 1. Clear H1 presence
3148
+ # 2. Table presence (LLMs love tables)
3149
+ # 3. Schema presence (structured data)
3150
+ # 4. Definition / Bullet presence
3151
+ html = pa.html || ''
3152
+ has_tables = html.include?('<table')
3153
+ has_lists = html.include?('<ul') || html.include?('<ol')
3154
+ schemas = data.dig(:structured_data, :schemas) || []
3155
+ h1_count = (data.dig(:headings, :h1) || []).length
3156
+
3157
+ score = 100
3158
+ issues = []
3159
+
3160
+ if h1_count != 1
3161
+ score -= 20
3162
+ issues << "H1 count is #{h1_count} (Must be exactly 1 for clean LLM hierarchy)"
3163
+ end
3164
+
3165
+ unless has_tables
3166
+ score -= 15
3167
+ issues << "No <table> found (tables increase LLM citation and fact extraction by 3x)"
3168
+ end
3169
+
3170
+ unless has_lists
3171
+ score -= 15
3172
+ issues << "No bullet lists (<ul> or <ol>) found for quick entity consumption"
3173
+ end
3174
+
3175
+ if schemas.empty?
3176
+ score -= 20
3177
+ issues << "No JSON-LD schemas detected (structured data accelerates AI knowledge graph inclusion)"
3178
+ end
3179
+
3180
+ {
3181
+ url: url,
3182
+ ai_readability_score: [score, 0].max,
3183
+ grade: score >= 80 ? 'A (Excellent)' : (score >= 60 ? 'B (Acceptable)' : 'C (Needs Work)'),
3184
+ issues: issues,
3185
+ features: {
3186
+ has_tables: has_tables,
3187
+ has_lists: has_lists,
3188
+ schemas_found: schemas.length,
3189
+ h1_count: h1_count
3190
+ }
3191
+ }
3192
+ end
3193
+ end
3194
+ end
3195
+ # --- End llms_generator.rb ---
3196
+
3197
+ # --- Begin serp_preview.rb ---
3198
+ module GSC
3199
+ class SerpPreview
3200
+ attr_reader :url, :data
3201
+
3202
+ def initialize(url)
3203
+ @url = url.to_s.strip
3204
+ end
3205
+
3206
+ def generate
3207
+ pa = GSC::PageAnalyzer.new(@url)
3208
+ @data = pa.fetch_and_analyze
3209
+
3210
+ title = @data.dig(:title, :text) || 'Untitled Page'
3211
+ desc = @data.dig(:meta_description, :text) || 'No meta description found.'
3212
+ canonical = @data.dig(:canonical, :url) || @url
3213
+
3214
+ # SERP pixel calculation approximation:
3215
+ # ~10px per character average for Arial 18px title
3216
+ title_chars = title.length
3217
+ is_truncated = title_chars > 60
3218
+
3219
+ desktop_title = is_truncated ? "#{title[0..56]}..." : title
3220
+ desktop_snippet = desc.length > 155 ? "#{desc[0..152]}..." : desc
3221
+
3222
+ og = @data[:open_graph] || {}
3223
+ twitter = @data[:twitter_card] || {}
3224
+
3225
+ {
3226
+ url: @url,
3227
+ canonical: canonical,
3228
+ title: title,
3229
+ meta_description: desc,
3230
+ truncation_risk: is_truncated,
3231
+ desktop_serp: {
3232
+ title: desktop_title,
3233
+ snippet: desktop_snippet,
3234
+ breadcrumb: format_breadcrumb(canonical)
3235
+ },
3236
+ social: {
3237
+ og_title: og['og:title'] || title,
3238
+ og_description: og['og:description'] || desc,
3239
+ og_image: og['og:image'],
3240
+ twitter_card: twitter['twitter:card'] || 'summary_large_image'
3241
+ }
3242
+ }
3243
+ end
3244
+
3245
+ private
3246
+
3247
+ def format_breadcrumb(url_str)
3248
+ uri = URI.parse(url_str) rescue nil
3249
+ return url_str unless uri && uri.host
3250
+
3251
+ domain = uri.host.sub(/^www\./, '')
3252
+ parts = uri.path.split('/').reject(&:empty?)
3253
+ if parts.empty?
3254
+ "https://#{domain}"
3255
+ else
3256
+ "https://#{domain} > #{parts.join(' > ')}"
3257
+ end
3258
+ end
3259
+ end
3260
+ end
3261
+ # --- End serp_preview.rb ---
3262
+
3263
+ # --- Begin network_tracer.rb ---
3264
+ module GSC
3265
+ class NetworkTracer
3266
+ attr_reader :start_url, :max_hops
3267
+
3268
+ def initialize(start_url, max_hops: 10)
3269
+ @start_url = start_url.to_s.strip
3270
+ @start_url = "http://#{@start_url}" unless @start_url =~ %r{^https?://}
3271
+ @max_hops = max_hops
3272
+ end
3273
+
3274
+ def trace
3275
+ current_url = @start_url
3276
+ hops = []
3277
+ visited = Set.new
3278
+
3279
+ @max_hops.times do |hop_idx|
3280
+ break if visited.include?(current_url)
3281
+ visited << current_url
3282
+
3283
+ uri = URI.parse(current_url) rescue nil
3284
+ break unless uri && uri.host
3285
+
3286
+ start_t = Time.now
3287
+ res = nil
3288
+
3289
+ begin
3290
+ Net::HTTP.start(uri.hostname, uri.port, use_ssl: (uri.scheme == 'https'), open_timeout: 5, read_timeout: 10) do |http|
3291
+ req = Net::HTTP::Get.new(uri.request_uri)
3292
+ req['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) gsc-cli/2.1'
3293
+ res = http.request(req)
3294
+ end
3295
+ rescue StandardError => e
3296
+ hops << {
3297
+ hop: hop_idx + 1,
3298
+ url: current_url,
3299
+ error: e.message
3300
+ }
3301
+ break
3302
+ end
3303
+
3304
+ duration_ms = ((Time.now - start_t) * 1000).round(1)
3305
+ status_code = res.code.to_i
3306
+ location = res['location']
3307
+
3308
+ hop_info = {
3309
+ hop: hop_idx + 1,
3310
+ url: current_url,
3311
+ status_code: status_code,
3312
+ duration_ms: duration_ms,
3313
+ x_robots_tag: res['x-robots-tag'],
3314
+ canonical_header: res['link'] =~ /rel="canonical"/i ? res['link'] : nil,
3315
+ hsts: res['strict-transport-security'],
3316
+ content_type: res['content-type'],
3317
+ server: res['server']
3318
+ }
3319
+
3320
+ hops << hop_info
3321
+
3322
+ if [301, 302, 303, 307, 308].include?(status_code) && location
3323
+ current_url = URI.join(current_url, location).to_s
3324
+ else
3325
+ break
3326
+ end
3327
+ end
3328
+
3329
+ total_time = hops.sum { |h| h[:duration_ms] || 0 }.round(1)
3330
+ final_hop = hops.last || {}
3331
+ is_redirect_chain = hops.length > 2
3332
+
3333
+ {
3334
+ start_url: @start_url,
3335
+ final_url: final_hop[:url],
3336
+ total_hops: hops.length,
3337
+ total_duration_ms: total_time,
3338
+ is_redirect_chain: is_redirect_chain,
3339
+ hops: hops
3340
+ }
3341
+ end
3342
+ end
3343
+ end
3344
+ # --- End network_tracer.rb ---
3345
+
3346
+ # --- Begin robots_checker.rb ---
3347
+ module GSC
3348
+ class RobotsChecker
3349
+ attr_reader :base_url, :robots_content
3350
+
3351
+ def initialize(target_url)
3352
+ @target_url = target_url.to_s.strip
3353
+ @target_url = "https://#{@target_url}" unless @target_url =~ %r{^https?://}
3354
+ @uri = URI.parse(@target_url)
3355
+ @robots_url = "#{@uri.scheme}://#{@uri.host}:#{@uri.port}/robots.txt"
3356
+ end
3357
+
3358
+ def fetch_robots_txt
3359
+ res = Net::HTTP.get_response(URI.parse(@robots_url))
3360
+ return '' unless res.code == '200'
3361
+
3362
+ res.body.force_encoding('UTF-8')
3363
+ rescue StandardError
3364
+ ''
3365
+ end
3366
+
3367
+ def check(path_to_test = nil, user_agent = 'googlebot')
3368
+ @robots_content ||= fetch_robots_txt
3369
+ path = path_to_test || @uri.path
3370
+ path = '/' if path.empty?
3371
+
3372
+ ua = user_agent.to_s.downcase
3373
+ rules = parse_rules(ua)
3374
+
3375
+ allowed = true
3376
+ matched_rule = nil
3377
+
3378
+ rules.each do |rule|
3379
+ pattern = rule[:path]
3380
+ regex = Regexp.new('^' + Regexp.escape(pattern).gsub('\*', '.*'))
3381
+ if path =~ regex
3382
+ allowed = (rule[:type] == :allow)
3383
+ matched_rule = rule
3384
+ end
3385
+ end
3386
+
3387
+ {
3388
+ robots_url: @robots_url,
3389
+ user_agent: user_agent,
3390
+ tested_path: path,
3391
+ allowed: allowed,
3392
+ matched_rule: matched_rule,
3393
+ has_robots_txt: !@robots_content.empty?
3394
+ }
3395
+ end
3396
+
3397
+ private
3398
+
3399
+ def parse_rules(target_ua)
3400
+ rules = []
3401
+ current_ua = nil
3402
+ applies = false
3403
+
3404
+ @robots_content.each_line do |line|
3405
+ line = line.strip.sub(/#.*$/, '')
3406
+ next if line.empty?
3407
+
3408
+ if line =~ /^User-agent:\s*(.+)$/i
3409
+ current_ua = $1.strip.downcase
3410
+ applies = (current_ua == '*' || current_ua == target_ua)
3411
+ elsif applies && line =~ /^Disallow:\s*(.*)$/i
3412
+ val = $1.strip
3413
+ rules << { type: :disallow, path: val } unless val.empty?
3414
+ elsif applies && line =~ /^Allow:\s*(.*)$/i
3415
+ val = $1.strip
3416
+ rules << { type: :allow, path: val } unless val.empty?
3417
+ end
3418
+ end
3419
+
3420
+ rules
3421
+ end
3422
+ end
3423
+ end
3424
+ # --- End robots_checker.rb ---
3425
+
3426
+ # --- Begin backlinks_manager.rb ---
3427
+ module GSC
3428
+ class BacklinksManager
3429
+ attr_reader :domain, :storage_file
3430
+
3431
+ def initialize(domain)
3432
+ @domain = clean_domain(domain)
3433
+ @domain_dir = File.join(Dir.home, '.config', 'gsc', 'domains', @domain)
3434
+ FileUtils.mkdir_p(@domain_dir)
3435
+ @storage_file = File.join(@domain_dir, 'backlinks.json')
3436
+ end
3437
+
3438
+ def load_data
3439
+ return { 'sources' => [], 'targets' => [], 'updated_at' => nil } unless File.exist?(@storage_file)
3440
+
3441
+ JSON.parse(File.read(@storage_file, encoding: 'UTF-8'))
3442
+ rescue StandardError
3443
+ { 'sources' => [], 'targets' => [], 'updated_at' => nil }
3444
+ end
3445
+
3446
+ def save_data(data)
3447
+ data['updated_at'] = Time.now.utc.iso8601
3448
+ File.write(@storage_file, JSON.pretty_generate(data))
3449
+ end
3450
+
3451
+ def import_csv(content)
3452
+ data = load_data
3453
+ lines = content.to_s.strip.lines
3454
+
3455
+ return { error: 'Empty content' } if lines.empty?
3456
+
3457
+ # Detect header
3458
+ header = lines.first.downcase
3459
+ if header.include?('top linking sites') || header.include?('root domain')
3460
+ # Sources CSV
3461
+ sources = []
3462
+ CSV.parse(content, headers: true, skip_blanks: true) do |row|
3463
+ domain_name = row['Top linking sites'] || row['Site'] || row[0]
3464
+ links_count = (row['Target pages'] || row['Links'] || row[1] || '1').to_s.gsub(',', '').to_i
3465
+ sources << { 'domain' => domain_name.to_s.strip, 'links_count' => links_count } if domain_name
3466
+ end
3467
+ data['sources'] = sources
3468
+ else
3469
+ # Target pages CSV
3470
+ targets = []
3471
+ CSV.parse(content, headers: true, skip_blanks: true) do |row|
3472
+ target_url = row['Target page'] || row['Page'] || row[0]
3473
+ incoming = (row['Incoming links'] || row['Links'] || row[1] || '1').to_s.gsub(',', '').to_i
3474
+ targets << { 'target_url' => target_url.to_s.strip, 'incoming_count' => incoming } if target_url
3475
+ end
3476
+ data['targets'] = targets
3477
+ end
3478
+
3479
+ save_data(data)
3480
+ {
3481
+ status: 'success',
3482
+ sources_count: data['sources'].length,
3483
+ targets_count: data['targets'].length,
3484
+ updated_at: data['updated_at']
3485
+ }
3486
+ rescue StandardError => e
3487
+ { error: "Failed to parse CSV: #{e.message}" }
3488
+ end
3489
+
3490
+ def summary(target_page: nil)
3491
+ data = load_data
3492
+ sources = data['sources'] || []
3493
+ targets = data['targets'] || []
3494
+
3495
+ filtered_targets = target_page ? targets.select { |t| t['target_url'].include?(target_page) } : targets
3496
+
3497
+ {
3498
+ domain: @domain,
3499
+ total_referring_domains: sources.length,
3500
+ total_external_links: sources.sum { |s| s['links_count'] || 0 },
3501
+ top_referring_domains: sources.sort_by { |s| -(s['links_count'] || 0) }.first(15),
3502
+ top_target_pages: filtered_targets.sort_by { |t| -(t['incoming_count'] || 0) }.first(15),
3503
+ updated_at: data['updated_at']
3504
+ }
3505
+ end
3506
+
3507
+ private
3508
+
3509
+ def clean_domain(input)
3510
+ d = input.to_s.strip.downcase
3511
+ d = d.sub(%r{^https?://}, '').sub(%r{/.*$}, '').sub(/^www\./, '')
3512
+ d
3513
+ end
3514
+ end
3515
+ end
3516
+ # --- End backlinks_manager.rb ---
3517
+
3518
+ # --- Begin cli_advanced.rb ---
3519
+ module GSC
3520
+ class CLI
3521
+ # 1. Google Suggest
3522
+ def self.handle_suggest_command(target, options)
3523
+ query = target.to_s.strip
3524
+ if query.empty?
3525
+ puts Color.c("❌ Error: Query required. Example: gsc suggest \"moving boxes\"", Color::RED)
3526
+ return
3527
+ end
3528
+
3529
+ alphabet = options[:alphabet] || false
3530
+ suggest = GSC::GoogleSuggest.new(query, options)
3531
+ results = suggest.fetch(alphabet: alphabet, questions: false)
3532
+
3533
+ if options[:json]
3534
+ puts JSON.pretty_generate(results)
3535
+ return
3536
+ end
3537
+
3538
+ puts BANNER unless options[:in_dashboard]
3539
+ puts "💡 #{Color::BOLD}GOOGLE SEARCH SUGGESTIONS:#{Color::RESET} #{Color.c(query, Color::CYAN)}"
3540
+ puts "─" * 70
3541
+
3542
+ if alphabet
3543
+ results.each do |key, list|
3544
+ next if list.empty?
3545
+ prefix = (key == 'root') ? "Root" : "+ #{key.upcase}"
3546
+ puts "\n#{Color.c(prefix, Color::BOLD, Color::YELLOW)}:"
3547
+ list.each do |item|
3548
+ puts " • #{item[:term]}"
3549
+ end
3550
+ end
3551
+ else
3552
+ if results.empty?
3553
+ puts " (No search suggestions returned)"
3554
+ else
3555
+ results.each_with_index do |item, idx|
3556
+ puts " #{Color.c((idx + 1).to_s.rjust(2), Color::DIM)}. #{Color.c(item[:term], Color::BOLD)}"
3557
+ end
3558
+ end
3559
+ end
3560
+ puts ""
3561
+ end
3562
+
3563
+ # 2. Questions / PAA
3564
+ def self.handle_questions_command(target, options)
3565
+ query = target.to_s.strip
3566
+ if query.empty?
3567
+ puts Color.c("❌ Error: Query required. Example: gsc questions \"packing dishes\"", Color::RED)
3568
+ return
3569
+ end
3570
+
3571
+ suggest = GSC::GoogleSuggest.new(query, options)
3572
+ results = suggest.fetch(questions: true)
3573
+
3574
+ if options[:json]
3575
+ puts JSON.pretty_generate(results)
3576
+ return
3577
+ end
3578
+
3579
+ puts BANNER unless options[:in_dashboard]
3580
+ puts "❓ #{Color::BOLD}SEARCH INTENT QUESTIONS & FAQs:#{Color::RESET} #{Color.c(query, Color::CYAN)}"
3581
+ puts "─" * 70
3582
+
3583
+ total_found = 0
3584
+ results.each do |prefix, list|
3585
+ next if list.empty?
3586
+ puts "\n#{Color.c(prefix.upcase, Color::BOLD, Color::CYAN)}:"
3587
+ list.each do |item|
3588
+ total_found += 1
3589
+ puts " • #{item[:term]}"
3590
+ end
3591
+ end
3592
+
3593
+ if total_found.zero?
3594
+ puts " (No question suggestions found for \"#{query}\")"
3595
+ end
3596
+ puts ""
3597
+ end
3598
+
3599
+ # 3. OpenPageRank Domain Authority
3600
+ def self.handle_authority_command(target, extra, options)
3601
+ domains = [target, extra].flatten.compact.reject { |d| d.to_s.strip.empty? }
3602
+ domains << Config.default_domain if domains.empty?
3603
+ domains = domains.compact
3604
+
3605
+ if domains.empty?
3606
+ puts Color.c("❌ Error: Domain required. Example: gsc authority packinglog.com", Color::RED)
3607
+ return
3608
+ end
3609
+
3610
+ opr = GSC::OpenPageRank.new
3611
+ unless opr.configured?
3612
+ puts Color.c("⚠️ OpenPageRank API key not configured.", Color::YELLOW, Color::BOLD)
3613
+ puts " Get a 100% free key (300,000 free queries/month) at: #{Color.c('https://openpagerank.com', Color::CYAN)}"
3614
+ puts " Then run: #{Color.c('gsc config set opr_api_key <YOUR_KEY>', Color::GREEN)}"
3615
+ puts " Or pass: #{Color.c('OPENPAGERANK_API_KEY=<KEY> gsc authority ...', Color::DIM)}"
3616
+ return
3617
+ end
3618
+
3619
+ data = opr.check_domains(domains)
3620
+
3621
+ if options[:json]
3622
+ puts JSON.pretty_generate(data)
3623
+ return
3624
+ end
3625
+
3626
+ puts BANNER unless options[:in_dashboard]
3627
+ puts "🌐 #{Color::BOLD}OPEN PAGERANK & DOMAIN AUTHORITY (Common Crawl Graph):#{Color::RESET}"
3628
+ puts "─" * 75
3629
+
3630
+ if data[:status] == 'error'
3631
+ puts Color.c("❌ Error: #{data[:message]}", Color::RED)
3632
+ return
3633
+ end
3634
+
3635
+ puts "#{'DOMAIN'.ljust(35)} #{'PAGERANK'.ljust(12)} #{'GLOBAL RANK'.ljust(18)} #{'STATUS'}"
3636
+ puts "─" * 75
3637
+
3638
+ (data[:records] || []).each do |rec|
3639
+ d_name = rec[:domain].to_s.ljust(35)
3640
+ pr = sprintf("%.2f / 10", rec[:page_rank_decimal]).ljust(12)
3641
+ gr = rec[:rank] ? "##{rec[:rank].to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse}".ljust(18) : "N/A".ljust(18)
3642
+ st = (rec[:status_code] == 200) ? Color.c("200 OK", Color::GREEN) : Color.c(rec[:status_code].to_s, Color::YELLOW)
3643
+
3644
+ puts "#{Color.c(d_name, Color::BOLD)} #{Color.c(pr, Color::CYAN)} #{Color.c(gr, Color::YELLOW)} #{st}"
3645
+ end
3646
+ puts ""
3647
+ end
3648
+
3649
+ # 4. PageSpeed Core Web Vitals
3650
+ def self.handle_speed_command(target, options)
3651
+ url = target || "https://#{Config.default_domain || 'example.com'}"
3652
+ url = "https://#{url}" unless url =~ %r{^https?://}
3653
+ strategy = options[:strategy] || 'mobile'
3654
+
3655
+ puts BANNER unless options[:json] || options[:in_dashboard]
3656
+ puts "⚡ Measuring Core Web Vitals via Google PageSpeed Insights (#{strategy.upcase}):" unless options[:json]
3657
+ puts " #{Color.c(url, Color::CYAN)}\n" unless options[:json]
3658
+
3659
+ ps = GSC::PageSpeed.new(url, strategy: strategy)
3660
+ data = ps.run
3661
+
3662
+ if options[:json]
3663
+ puts JSON.pretty_generate(data)
3664
+ return
3665
+ end
3666
+
3667
+ if data[:error]
3668
+ puts Color.c("❌ PageSpeed API Error: #{data[:message]}", Color::RED)
3669
+ return
3670
+ end
3671
+
3672
+ perf_score = data[:performance_score]
3673
+ score_color = perf_score >= 90 ? Color::GREEN : (perf_score >= 50 ? Color::YELLOW : Color::RED)
3674
+
3675
+ puts "╔══════════════════════════════════════════════════════════════╗"
3676
+ puts "║ Lighthouse Performance Score: #{Color.c(perf_score.to_s.rjust(3) + ' / 100', score_color, Color::BOLD)} ║"
3677
+ puts "║ Lighthouse SEO Score : #{Color.c(data[:seo_score].to_s.rjust(3) + ' / 100', Color::GREEN, Color::BOLD)} ║"
3678
+ puts "╚══════════════════════════════════════════════════════════════╝"
3679
+
3680
+ m = data[:metrics] || {}
3681
+ puts "\n#{Color::BOLD}📊 CORE WEB VITALS (Lab Metrics):#{Color::RESET}"
3682
+ puts " • LCP (Largest Contentful Paint) : #{Color.c(m[:lcp] || 'N/A', Color::BOLD)}"
3683
+ puts " • FCP (First Contentful Paint) : #{Color.c(m[:fcp] || 'N/A', Color::BOLD)}"
3684
+ puts " • CLS (Cumulative Layout Shift) : #{Color.c(m[:cls] || 'N/A', Color::BOLD)}"
3685
+ puts " • TBT (Total Blocking Time) : #{Color.c(m[:tbt] || 'N/A', Color::BOLD)}"
3686
+ puts " • Speed Index : #{Color.c(m[:speed_index] || 'N/A', Color::BOLD)}"
3687
+
3688
+ opps = data[:opportunities] || []
3689
+ unless opps.empty?
3690
+ puts "\n#{Color::BOLD}💡 TOP SPEED OPPORTUNITIES:#{Color::RESET}"
3691
+ opps.each do |opp|
3692
+ puts " • #{opp[:title]}: #{Color.c(opp[:display] || "#{opp[:savings_ms]}ms savings", Color::YELLOW)}"
3693
+ end
3694
+ end
3695
+ puts ""
3696
+ end
3697
+
3698
+ # 5. Page Comparison
3699
+ def self.handle_compare_command(target, extra, options)
3700
+ url1 = target
3701
+ url2 = extra
3702
+
3703
+ if url1.nil? || url2.nil?
3704
+ puts Color.c("❌ Error: Two URLs required. Example: gsc compare https://site.com/p1 https://competitor.com/p2", Color::RED)
3705
+ return
3706
+ end
3707
+
3708
+ comp = GSC::PageComparator.new(url1, url2)
3709
+ data = comp.compare
3710
+
3711
+ if options[:json]
3712
+ puts JSON.pretty_generate(data)
3713
+ return
3714
+ end
3715
+
3716
+ puts BANNER unless options[:in_dashboard]
3717
+ puts "🥊 #{Color::BOLD}HEAD-TO-HEAD SEO ON-PAGE COMPARISON:#{Color::RESET}"
3718
+ puts " Page 1 (Target): #{Color.c(url1, Color::CYAN)}"
3719
+ puts " Page 2 (Competitor): #{Color.c(url2, Color::YELLOW)}"
3720
+ puts "─" * 80
3721
+
3722
+ c = data[:comparison] || {}
3723
+
3724
+ # Meta Titles
3725
+ t1 = c.dig(:meta, :title, :page1) || {}
3726
+ t2 = c.dig(:meta, :title, :page2) || {}
3727
+ puts "\n#{Color::BOLD}📑 TITLE TAG:#{Color::RESET}"
3728
+ puts " P1: #{t1[:text]} (#{t1[:length]} chars) [#{t1[:optimal] ? Color.c('Optimal', Color::GREEN) : Color.c('Review', Color::YELLOW)}]"
3729
+ puts " P2: #{t2[:text]} (#{t2[:length]} chars) [#{t2[:optimal] ? Color.c('Optimal', Color::GREEN) : Color.c('Review', Color::YELLOW)}]"
3730
+
3731
+ # Headings
3732
+ h = c[:headings] || {}
3733
+ puts "\n#{Color::BOLD}🏷️ HEADINGS H1 / H2:#{Color::RESET}"
3734
+ puts " P1: #{h.dig(:h1_count, :page1)} H1s | #{h.dig(:h2_count, :page1)} H2s"
3735
+ puts " P2: #{h.dig(:h1_count, :page2)} H1s | #{h.dig(:h2_count, :page2)} H2s"
3736
+
3737
+ # Images
3738
+ img = c[:images] || {}
3739
+ puts "\n#{Color::BOLD}🖼️ IMAGES & ACCESSIBILITY:#{Color::RESET}"
3740
+ puts " P1: #{img.dig(:total_images, :page1)} images (#{img.dig(:missing_alt, :page1)} missing alt)"
3741
+ puts " P2: #{img.dig(:total_images, :page2)} images (#{img.dig(:missing_alt, :page2)} missing alt)"
3742
+
3743
+ # Links
3744
+ l = c[:links] || {}
3745
+ puts "\n#{Color::BOLD}🔗 LINK COUNTS:#{Color::RESET}"
3746
+ puts " P1: #{l.dig(:internal, :page1)} internal | #{l.dig(:external, :page1)} external"
3747
+ puts " P2: #{l.dig(:internal, :page2)} internal | #{l.dig(:external, :page2)} external"
3748
+
3749
+ # Schema
3750
+ s = c[:structured_data] || {}
3751
+ puts "\n#{Color::BOLD}📦 STRUCTURED DATA (JSON-LD):#{Color::RESET}"
3752
+ puts " P1: #{s.dig(:schema_count, :page1)} schemas #{(s.dig(:schema_types, :page1) || []).inspect}"
3753
+ puts " P2: #{s.dig(:schema_count, :page2)} schemas #{(s.dig(:schema_types, :page2) || []).inspect}"
3754
+
3755
+ # Speed
3756
+ p_time = c[:performance] || {}
3757
+ puts "\n#{Color::BOLD}⚡ RESPONSE TIME:#{Color::RESET}"
3758
+ puts " P1: #{p_time.dig(:response_time_ms, :page1)}ms | P2: #{p_time.dig(:response_time_ms, :page2)}ms"
3759
+ puts ""
3760
+ end
3761
+
3762
+ # 6. Content Gap
3763
+ def self.handle_content_gap_command(target, extra, options)
3764
+ url1 = target
3765
+ url2 = extra
3766
+
3767
+ if url1.nil? || url2.nil?
3768
+ puts Color.c("❌ Error: Two URLs required. Example: gsc content-gap https://mysite.com https://competitor.com", Color::RED)
3769
+ return
3770
+ end
3771
+
3772
+ gap = GSC::ContentGap.new(url1, url2)
3773
+ data = gap.analyze
3774
+
3775
+ if options[:json]
3776
+ puts JSON.pretty_generate(data)
3777
+ return
3778
+ end
3779
+
3780
+ puts BANNER unless options[:in_dashboard]
3781
+ puts "🔍 #{Color::BOLD}CONTENT & TOPICAL KEYWORD GAP (SurferSEO Style):#{Color::RESET}"
3782
+ puts " My URL: #{Color.c(url1, Color::CYAN)} (#{data[:page1][:word_count]} words)"
3783
+ puts " Competitor URL: #{Color.c(url2, Color::YELLOW)} (#{data[:page2][:word_count]} words)"
3784
+ puts "─" * 80
3785
+
3786
+ puts "\n#{Color::BOLD}🎯 HIGH-FREQUENCY PHRASES IN COMPETITOR MISSING IN YOUR CONTENT:#{Color::RESET}"
3787
+ unigrams = data[:missing_unigrams] || []
3788
+ bigrams = data[:missing_bigrams] || []
3789
+
3790
+ if unigrams.empty? && bigrams.empty?
3791
+ puts " (No major content gap detected! Your page covers competitor terminology well.)"
3792
+ else
3793
+ puts "\n #{Color.c('Top Missing 2-Word Keyphrases:', Color::BOLD, Color::YELLOW)}"
3794
+ bigrams.first(8).each do |b|
3795
+ puts " • \"#{Color.c(b[:term], Color::BOLD)}\" (Competitor uses #{b[:competitor_count]}x, You: #{b[:your_count]}x)"
3796
+ end
3797
+
3798
+ puts "\n #{Color.c('Top Missing Keywords:', Color::BOLD, Color::CYAN)}"
3799
+ unigrams.first(8).each do |u|
3800
+ puts " • \"#{Color.c(u[:term], Color::BOLD)}\" (Competitor uses #{u[:competitor_count]}x, You: #{u[:your_count]}x)"
3801
+ end
3802
+ end
3803
+
3804
+ headings = data[:missing_headings] || []
3805
+ unless headings.empty?
3806
+ puts "\n#{Color::BOLD}📑 COMPETITOR HEADINGS / TOPICS YOU OMITTED:#{Color::RESET}"
3807
+ headings.each do |h|
3808
+ puts " • #{h}"
3809
+ end
3810
+ end
3811
+ puts ""
3812
+ end
3813
+
3814
+ # 7. Internal Links Audit
3815
+ def self.handle_internal_links_command(target, options)
3816
+ base_url = target || "https://#{Config.default_domain || 'example.com'}"
3817
+ il = GSC::InternalLinks.new(base_url, limit: options[:limit] || 50)
3818
+
3819
+ puts BANNER unless options[:json] || options[:in_dashboard]
3820
+ puts "🕸️ Auditing Internal Links & Orphan Pages for: #{Color.c(base_url, Color::CYAN)}...\n" unless options[:json]
3821
+
3822
+ data = il.audit
3823
+
3824
+ if options[:json]
3825
+ puts JSON.pretty_generate(data)
3826
+ return
3827
+ end
3828
+
3829
+ puts "─" * 80
3830
+ puts "Pages Discovered: #{Color.c(data[:total_pages].to_s, Color::BOLD)}"
3831
+ puts "Orphan Pages: #{Color.c(data[:orphans].length.to_s, data[:orphans].empty? ? Color::GREEN : Color::RED, Color::BOLD)}"
3832
+ puts "Weakly Linked: #{Color.c(data[:weak_pages].length.to_s, Color::YELLOW, Color::BOLD)} (Only 1 incoming internal link)"
3833
+ puts "─" * 80
3834
+
3835
+ orphans = data[:orphans] || []
3836
+ unless orphans.empty?
3837
+ puts "\n#{Color::BOLD}🚨 ORPHAN PAGES (0 incoming internal links - Crawl Dead Ends):#{Color::RESET}"
3838
+ orphans.each do |orp|
3839
+ puts " • #{Color.c(orp, Color::RED)}"
2174
3840
  end
2175
3841
  end
2176
- md << ""
2177
- md << "---"
2178
- md << ""
2179
3842
 
2180
- # 3. Image Alt Tag Fixes
2181
- md << "## 🖼️ 3. Images Missing Alt Attributes (#{summary[:missing_alts_count]} Found)"
2182
- md << ""
2183
- if @missing_alts.empty?
2184
- md << "✅ **All images on audited pages have descriptive alt text!**"
2185
- else
2186
- md << "Search engines and screen readers rely on descriptive `alt` attributes to index visual content:"
2187
- md << ""
2188
- md << "| Page URL | Image Source URL | Recommended Fix |"
2189
- md << "| :--- | :--- | :--- |"
2190
- @missing_alts.each do |img|
2191
- md << "| `#{img[:page_url]}` | `#{img[:src]}` | Add descriptive keywords to `alt=\"...\"` |"
3843
+ puts "\n#{Color::BOLD}🔝 MOST LINKED INTERNAL PAGES:#{Color::RESET}"
3844
+ (data[:top_linked] || []).each do |top|
3845
+ puts " • #{top[:url]} (#{Color.c(top[:incoming_count].to_s, Color::CYAN)} incoming links)"
3846
+ end
3847
+ puts ""
3848
+ end
3849
+
3850
+ # 8. Schema Validator & Generator
3851
+ def self.handle_schema_command(target, extra, options)
3852
+ if target == 'generate' || target == 'gen'
3853
+ schema_type = extra || 'faq'
3854
+ tpl = GSC::SchemaValidator.generate_template(schema_type)
3855
+ if options[:json]
3856
+ puts JSON.pretty_generate(tpl)
3857
+ else
3858
+ puts Color.c("📋 Generated JSON-LD Schema (#{schema_type}):", Color::GREEN, Color::BOLD)
3859
+ puts "<script type=\"application/ld+json\">"
3860
+ puts JSON.pretty_generate(tpl)
3861
+ puts "</script>"
2192
3862
  end
3863
+ return
2193
3864
  end
2194
- md << ""
2195
- md << "---"
2196
- md << ""
2197
3865
 
2198
- # 4. Heading Hierarchy Flaws
2199
- md << "## 📑 4. Heading Hierarchy & H1 Flaws (#{summary[:heading_issues_count]} Found)"
2200
- md << ""
2201
- if @heading_issues.empty?
2202
- md << "✅ **All pages have exactly one <h1> and clean structure!**"
3866
+ url = target || "https://#{Config.default_domain || 'example.com'}"
3867
+ sv = GSC::SchemaValidator.new(url)
3868
+ data = sv.audit
3869
+
3870
+ if options[:json]
3871
+ puts JSON.pretty_generate(data)
3872
+ return
3873
+ end
3874
+
3875
+ puts BANNER unless options[:in_dashboard]
3876
+ puts "📦 #{Color::BOLD}STRUCTURED DATA & RICH SNIPPET VALIDATION:#{Color::RESET} #{Color.c(url, Color::CYAN)}"
3877
+ puts "─" * 75
3878
+
3879
+ schemas = data[:schemas] || []
3880
+ if schemas.empty?
3881
+ puts " (No JSON-LD structured data schemas found on this page)"
3882
+ puts " Tip: Run `gsc schema generate faq` to create valid JSON-LD schema."
2203
3883
  else
2204
- md << "| Page URL | Issue Details | Recommended Action |"
2205
- md << "| :--- | :--- | :--- |"
2206
- @heading_issues.each do |h|
2207
- md << "| `#{h[:page_url]}` | #{h[:issue]} | Ensure exactly one <h1> matching primary search query |"
3884
+ schemas.each do |sc|
3885
+ valid_badge = sc[:valid] ? Color.c("VALID", Color::GREEN, Color::BOLD) : Color.c("INVALID", Color::RED, Color::BOLD)
3886
+ puts "\nSchema ##{sc[:index] + 1}: #{Color.c(sc[:type], Color::BOLD)} [#{valid_badge}]"
3887
+ (sc[:errors] || []).each { |e| puts " ❌ Error: #{Color.c(e, Color::RED)}" }
3888
+ (sc[:warnings] || []).each { |w| puts " ⚠️ Warning: #{Color.c(w, Color::YELLOW)}" }
2208
3889
  end
2209
3890
  end
2210
- md << ""
2211
- md << "---"
2212
- md << ""
3891
+ puts ""
3892
+ end
2213
3893
 
2214
- # 5. Title & Meta Description Flaws
2215
- md << "## 🏷️ 5. Title & Meta Description Optimizations (#{summary[:title_meta_issues_count]} Found)"
2216
- md << ""
2217
- if @title_issues.empty?
2218
- md << "✅ **All titles and meta descriptions meet 30–60 char and 70–155 char standards!**"
3894
+ # 9. LLMS.txt & AI Search
3895
+ def self.handle_llms_command(target, extra, options)
3896
+ base_url = target || "https://#{Config.default_domain || 'example.com'}"
3897
+ llms = GSC::LlmsGenerator.new(base_url)
3898
+
3899
+ if extra == 'audit' || options[:audit]
3900
+ data = llms.audit_ai_readability(base_url)
3901
+ if options[:json]
3902
+ puts JSON.pretty_generate(data)
3903
+ else
3904
+ puts BANNER unless options[:in_dashboard]
3905
+ puts "🤖 #{Color::BOLD}AI SEARCH ENGINE / LLM READABILITY AUDIT:#{Color::RESET} #{Color.c(base_url, Color::CYAN)}"
3906
+ puts "─" * 70
3907
+ puts "AI Citation Readiness Score: #{Color.c(data[:ai_readability_score].to_s + '/100', Color::GREEN, Color::BOLD)} [Grade: #{data[:grade]}]"
3908
+ puts "\nFeatures Detected:"
3909
+ puts " • Single H1 Heading : #{data.dig(:features, :h1_count) == 1 ? '✅ Yes' : '❌ No'}"
3910
+ puts " • Tables for Data : #{data.dig(:features, :has_tables) ? '✅ Yes' : '❌ No'}"
3911
+ puts " • Bullet Lists : #{data.dig(:features, :has_lists) ? '✅ Yes' : '❌ No'}"
3912
+ puts " • Structured Data : #{data.dig(:features, :schemas_found)} schemas"
3913
+ unless data[:issues].empty?
3914
+ puts "\nOptimizations for Perplexity & ChatGPT:"
3915
+ data[:issues].each { |iss| puts " • #{Color.c(iss, Color::YELLOW)}" }
3916
+ end
3917
+ puts ""
3918
+ end
2219
3919
  else
2220
- md << "| Page URL | Current Title (Chars) | Current Meta (Chars) | Flaw Detected |"
2221
- md << "| :--- | :--- | :--- | :--- |"
2222
- @title_issues.each do |t|
2223
- md << "| `#{t[:page_url]}` | #{t[:title]} (#{t[:title_chars]}c) | #{t[:meta]} (#{t[:meta_chars]}c) | #{t[:flaw]} |"
3920
+ content = llms.generate_llms_txt
3921
+ if options[:save]
3922
+ File.write("llms.txt", content)
3923
+ puts Color.c(" Successfully wrote llms.txt to current directory!", Color::GREEN)
3924
+ else
3925
+ puts content
2224
3926
  end
2225
3927
  end
2226
- md << ""
2227
- md << "---"
2228
- md << ""
3928
+ end
2229
3929
 
2230
- # 6. Prioritized AI Action Sprint
2231
- md << "## 🤖 6. AI Agent Automated Fix Sprint"
2232
- md << ""
2233
- md << "Use these instructions to locate template files and apply fixes:"
2234
- md << ""
2235
- md << "1. **P0: Fix Broken Links**: Locate `<a href=\"...\">` tags pointing to dead URLs identified in Section 2."
2236
- md << "2. **P0: Single <h1> Enforcement**: Ensure all template layouts have exactly one `<h1>`."
2237
- md << "3. **P1: Image Alt Tag Insertion**: Add descriptive `alt` attributes to all images in Section 3."
2238
- md << "4. **P1: Title Truncation Fix**: Keep `<title>` under 60 characters and `<meta name=\"description\">` under 155 characters."
2239
- md << "5. **P2: Googlebot Re-Index**: Ping Google's Indexing API for all updated URLs via `gsc index <url>`."
2240
- md << ""
2241
- md << "---\n*Report generated by `gsc site-audit` (On-Page & Off-Page SEO Engine)*"
3930
+ # 10. SERP & Social Preview
3931
+ def self.handle_preview_command(target, options)
3932
+ url = target || "https://#{Config.default_domain || 'example.com'}"
3933
+ sp = GSC::SerpPreview.new(url)
3934
+ data = sp.generate
2242
3935
 
2243
- File.write(filepath, md.join("\n"), encoding: 'UTF-8')
2244
- filepath
3936
+ if options[:json]
3937
+ puts JSON.pretty_generate(data)
3938
+ return
3939
+ end
3940
+
3941
+ puts BANNER unless options[:in_dashboard]
3942
+ puts "🖥️ #{Color::BOLD}GOOGLE SERP PREVIEW (Desktop Viewport):#{Color::RESET}"
3943
+ puts "┌─────────────────────────────────────────────────────────────┐"
3944
+ puts "│ #{Color.c(data.dig(:desktop_serp, :breadcrumb), Color::DIM)}│"
3945
+ puts "│ #{Color.c(data.dig(:desktop_serp, :title).ljust(59), Color::BLUE, Color::BOLD)}│"
3946
+ puts "│ #{Color.c(data.dig(:desktop_serp, :snippet)[0..58].ljust(59), Color::DIM)}│"
3947
+ puts "└─────────────────────────────────────────────────────────────┘"
3948
+ if data[:truncation_risk]
3949
+ puts Color.c("⚠️ Warning: Title exceeds 60 characters and may truncate with '...' on Google SERPs.", Color::YELLOW)
3950
+ else
3951
+ puts Color.c("✅ Title length is optimal (< 60 chars / ~580px).", Color::GREEN)
3952
+ end
3953
+
3954
+ puts "\n📱 #{Color::BOLD}OPEN GRAPH / SOCIAL CARD PREVIEW:#{Color::RESET}"
3955
+ soc = data[:social] || {}
3956
+ puts " • Title : #{soc[:og_title]}"
3957
+ puts " • Description : #{soc[:og_description]}"
3958
+ puts " • Card Image : #{soc[:og_image] || '(No og:image specified)'}"
3959
+ puts ""
2245
3960
  end
2246
3961
 
2247
- def aggregate_summary
2248
- critical_errors = @broken_links.size + @results.count { |r| r[:indexability][:noindex] }
2249
- total_issues = critical_errors + @missing_alts.size + @heading_issues.size + @title_issues.size + @canonical_issues.size
3962
+ # 11. Network & Redirect Tracer
3963
+ def self.handle_trace_command(target, options)
3964
+ url = target || Config.default_domain || 'example.com'
3965
+ nt = GSC::NetworkTracer.new(url)
3966
+ data = nt.trace
2250
3967
 
2251
- {
2252
- total_pages: @results.size,
2253
- total_issues: total_issues,
2254
- critical_errors_count: critical_errors,
2255
- broken_links_count: @broken_links.size,
2256
- missing_alts_count: @missing_alts.size,
2257
- heading_issues_count: @heading_issues.size,
2258
- title_meta_issues_count: @title_issues.size,
2259
- canonical_issues_count: @canonical_issues.size
2260
- }
3968
+ if options[:json]
3969
+ puts JSON.pretty_generate(data)
3970
+ return
3971
+ end
3972
+
3973
+ puts BANNER unless options[:in_dashboard]
3974
+ puts "🛤️ #{Color::BOLD}REDIRECT CHAIN & HTTP HEADER TRACE:#{Color::RESET} #{Color.c(url, Color::CYAN)}"
3975
+ puts "─" * 75
3976
+ puts "Total Hops: #{data[:total_hops]} | Duration: #{data[:total_duration_ms]}ms"
3977
+
3978
+ (data[:hops] || []).each do |hop|
3979
+ status_c = (hop[:status_code] == 200) ? Color::GREEN : Color::YELLOW
3980
+ puts "\nHop ##{hop[:hop]}: #{Color.c(hop[:status_code].to_s, status_c, Color::BOLD)} (#{hop[:duration_ms]}ms)"
3981
+ puts " URL: #{hop[:url]}"
3982
+ puts " X-Robots-Tag: #{Color.c(hop[:x_robots_tag], Color::RED)}" if hop[:x_robots_tag]
3983
+ puts " Canonical: #{hop[:canonical_header]}" if hop[:canonical_header]
3984
+ puts " HSTS: #{hop[:hsts] ? 'Enabled' : 'Disabled'}"
3985
+ end
3986
+ puts ""
2261
3987
  end
2262
3988
 
2263
- private
3989
+ # 12. Robots.txt Checker
3990
+ def self.handle_robots_command(target, extra, options)
3991
+ url = target || "https://#{Config.default_domain || 'example.com'}"
3992
+ path = extra || '/'
3993
+ bot = options[:bot] || 'googlebot'
2264
3994
 
2265
- def discover_urls(target)
2266
- if target.end_with?('.xml') || target.include?('sitemap')
2267
- SitemapLoader.load_urls(target)
2268
- elsif File.file?(target)
2269
- [target]
2270
- else
2271
- normalized = target.start_with?('http') ? target : "https://#{target}"
2272
- sitemap_url = "#{normalized.sub(%r{/+$}, '')}/sitemap.xml"
2273
- urls = SitemapLoader.load_urls(sitemap_url)
2274
- urls.empty? ? [normalized] : urls
3995
+ rc = GSC::RobotsChecker.new(url)
3996
+ data = rc.check(path, bot)
3997
+
3998
+ if options[:json]
3999
+ puts JSON.pretty_generate(data)
4000
+ return
2275
4001
  end
2276
- rescue StandardError
2277
- [target.start_with?('http') ? target : "https://#{target}"]
4002
+
4003
+ puts BANNER unless options[:in_dashboard]
4004
+ puts "🤖 #{Color::BOLD}ROBOTS.TXT CRAWLER SIMULATOR:#{Color::RESET}"
4005
+ puts " Robots URL : #{data[:robots_url]}"
4006
+ puts " User Agent : #{Color.c(bot, Color::CYAN)}"
4007
+ puts " Test Path : #{Color.c(path, Color::BOLD)}"
4008
+ puts "─" * 70
4009
+
4010
+ status_badge = data[:allowed] ? Color.c("✅ ALLOWED", Color::GREEN, Color::BOLD) : Color.c("❌ BLOCKED (DISALLOW)", Color::RED, Color::BOLD)
4011
+ puts "Crawl Verdict : #{status_badge}"
4012
+ if data[:matched_rule]
4013
+ puts "Matched Rule : #{data[:matched_rule][:type].to_s.upcase}: #{data[:matched_rule][:path]}"
4014
+ end
4015
+ puts ""
2278
4016
  end
2279
4017
 
2280
- def categorize_page_issues(data)
2281
- page_url = data[:url]
4018
+ # 13. Backlinks & GSC Links Ingestion
4019
+ def self.handle_backlinks_command(target, extra, options)
4020
+ domain = target || Config.default_domain || 'example.com'
4021
+ bm = GSC::BacklinksManager.new(domain)
2282
4022
 
2283
- # Broken links
2284
- if data.dig(:links, :verification)
2285
- data[:links][:verification].each do |link|
2286
- if !link[:ok]
2287
- @broken_links << {
2288
- source_page: page_url,
2289
- href: link[:href],
2290
- anchor: link[:anchor],
2291
- status: link[:status] || 'Error'
2292
- }
2293
- end
4023
+ if target == 'import' || extra == 'import'
4024
+ source = (target == 'import') ? extra : target
4025
+ content = if source == 'clip' || source == 'clipboard'
4026
+ `pbpaste 2>/dev/null`
4027
+ elsif source && File.exist?(source)
4028
+ File.read(source)
4029
+ else
4030
+ nil
4031
+ end
4032
+
4033
+ if content.nil? || content.strip.empty?
4034
+ puts Color.c("❌ Error: No content provided. Usage: gsc backlinks import [file.csv|clip]", Color::RED)
4035
+ return
2294
4036
  end
2295
- end
2296
4037
 
2297
- # Missing alts
2298
- if data.dig(:images, :missing_alt_images)
2299
- data[:images][:missing_alt_images].each do |img|
2300
- @missing_alts << {
2301
- page_url: page_url,
2302
- src: img[:src]
2303
- }
4038
+ res = bm.import_csv(content)
4039
+ if options[:json]
4040
+ puts JSON.pretty_generate(res)
4041
+ else
4042
+ puts Color.c("✅ Successfully imported GSC backlink export!", Color::GREEN, Color::BOLD)
4043
+ puts " Referring Domains : #{res[:sources_count]}"
4044
+ puts " Target Pages : #{res[:targets_count]}"
2304
4045
  end
4046
+ return
2305
4047
  end
2306
4048
 
2307
- # Headings
2308
- h1_count = data.dig(:headings, :h1_count) || 0
2309
- if h1_count == 0
2310
- @heading_issues << { page_url: page_url, issue: "Missing <h1> tag (0 found)" }
2311
- elsif h1_count > 1
2312
- @heading_issues << { page_url: page_url, issue: "Multiple <h1> tags (#{h1_count} found)" }
2313
- end
4049
+ data = bm.summary
2314
4050
 
2315
- # Title & Meta
2316
- title_chars = data.dig(:title, :length) || 0
2317
- meta_chars = data.dig(:meta_description, :length) || 0
2318
- title_text = data.dig(:title, :text) || ''
2319
- meta_text = data.dig(:meta_description, :text) || ''
4051
+ if options[:json]
4052
+ puts JSON.pretty_generate(data)
4053
+ return
4054
+ end
2320
4055
 
2321
- flaws = []
2322
- flaws << "Title > 60 chars" if title_chars > 60
2323
- flaws << "Title < 30 chars" if title_chars > 0 && title_chars < 30
2324
- flaws << "Missing Title" if title_chars == 0
2325
- flaws << "Meta > 155 chars" if meta_chars > 155
2326
- flaws << "Missing Meta" if meta_chars == 0
4056
+ puts BANNER unless options[:in_dashboard]
4057
+ puts "🔗 #{Color::BOLD}GSC BACKLINK & REFERRING DOMAIN INTELLIGENCE:#{Color::RESET} #{Color.c(domain, Color::CYAN)}"
4058
+ puts "" * 75
4059
+ puts "Total Referring Domains : #{Color.c(data[:total_referring_domains].to_s, Color::BOLD)}"
4060
+ puts "Total External Links : #{Color.c(data[:total_external_links].to_s, Color::BOLD)}"
4061
+ puts "Last Updated : #{data[:updated_at] || 'Never (Run `gsc backlinks import` to ingest GSC export)'}"
4062
+ puts "─" * 75
2327
4063
 
2328
- if !flaws.empty?
2329
- @title_issues << {
2330
- page_url: page_url,
2331
- title: title_text[0..40],
2332
- title_chars: title_chars,
2333
- meta: meta_text[0..40],
2334
- meta_chars: meta_chars,
2335
- flaw: flaws.join(', ')
2336
- }
4064
+ sources = data[:top_referring_domains] || []
4065
+ unless sources.empty?
4066
+ puts "\n#{Color::BOLD}🌐 TOP REFERRING SITES:#{Color::RESET}"
4067
+ sources.each do |s|
4068
+ puts " • #{s['domain'].to_s.ljust(45)} #{Color.c(s['links_count'].to_s.rjust(6) + ' links', Color::CYAN)}"
4069
+ end
2337
4070
  end
2338
4071
 
2339
- # Canonical
2340
- if data.dig(:canonical, :url) && !data.dig(:canonical, :self_referencing)
2341
- @canonical_issues << {
2342
- page_url: page_url,
2343
- canonical_url: data[:canonical][:url]
2344
- }
4072
+ targets = data[:top_target_pages] || []
4073
+ unless targets.empty?
4074
+ puts "\n#{Color::BOLD}🎯 TOP LINKED LANDING PAGES:#{Color::RESET}"
4075
+ targets.each do |t|
4076
+ puts " • #{t['target_url'].to_s.ljust(45)} #{Color.c(t['incoming_count'].to_s.rjust(6) + ' links', Color::GREEN)}"
4077
+ end
2345
4078
  end
4079
+ puts ""
2346
4080
  end
2347
4081
  end
2348
4082
  end
4083
+ # --- End cli_advanced.rb ---
2349
4084
 
2350
- # --- command_registry.rb ---
4085
+ # --- Begin command_registry.rb ---
2351
4086
  module GSC
2352
4087
  module CommandRegistry
2353
4088
  COMMAND_REGISTRY = [
@@ -2681,8 +4416,9 @@ COMMAND_REGISTRY = [
2681
4416
  COMMAND_REGISTRY = CommandRegistry::COMMAND_REGISTRY
2682
4417
  SKILL_MD_CONTENT = CommandRegistry::SKILL_MD_CONTENT
2683
4418
  end
4419
+ # --- End command_registry.rb ---
2684
4420
 
2685
- # --- cli.rb ---
4421
+ # --- Begin cli.rb ---
2686
4422
  module GSC
2687
4423
  # CLI Command Runner
2688
4424
  class CLI
@@ -2844,6 +4580,22 @@ opts.on('--save', 'Save keyword research snapshot to ~/.config/gsc/domains/<doma
2844
4580
  options[:save] = true
2845
4581
  end
2846
4582
 
4583
+ opts.on('--alphabet', 'Run alphabet soup harvest (a-z) for search suggestions') do
4584
+ options[:alphabet] = true
4585
+ end
4586
+
4587
+ opts.on('--numbers', 'Include numbers (0-9) in alphabet soup search suggestions') do
4588
+ options[:numbers] = true
4589
+ end
4590
+
4591
+ opts.on('--strategy STRAT', 'PageSpeed device strategy: mobile or desktop (default: mobile)') do |s|
4592
+ options[:strategy] = s
4593
+ end
4594
+
4595
+ opts.on('--bot NAME', 'User agent bot name for robots.txt testing (default: googlebot)') do |b|
4596
+ options[:bot] = b
4597
+ end
4598
+
2847
4599
 
2848
4600
  opts.on('--delay MS', Integer, 'Delay between sequential requests in ms (default: 120)') do |delay|
2849
4601
  options[:delay] = delay
@@ -2913,6 +4665,72 @@ opts.on('--dry-run', 'Simulate API calls without mutating data') do
2913
4665
  exit 0 unless options[:in_dashboard]
2914
4666
  return
2915
4667
 
4668
+ when 'suggest', 'autocomplete', 'sug'
4669
+ handle_suggest_command(target, options)
4670
+ exit 0 unless options[:in_dashboard]
4671
+ return if options[:in_dashboard]
4672
+
4673
+ when 'questions', 'paa', 'faqs'
4674
+ handle_questions_command(target, options)
4675
+ exit 0 unless options[:in_dashboard]
4676
+ return if options[:in_dashboard]
4677
+
4678
+ when 'authority', 'opr', 'da', 'domain-authority'
4679
+ handle_authority_command(target, extra, options)
4680
+ exit 0 unless options[:in_dashboard]
4681
+ return if options[:in_dashboard]
4682
+
4683
+ when 'speed', 'vitals', 'pagespeed', 'psi'
4684
+ handle_speed_command(target, options)
4685
+ exit 0 unless options[:in_dashboard]
4686
+ return if options[:in_dashboard]
4687
+
4688
+ when 'compare', 'diff-seo', 'vs'
4689
+ handle_compare_command(target, extra, options)
4690
+ exit 0 unless options[:in_dashboard]
4691
+ return if options[:in_dashboard]
4692
+
4693
+ when 'content-gap', 'gap'
4694
+ handle_content_gap_command(target, extra, options)
4695
+ exit 0 unless options[:in_dashboard]
4696
+ return if options[:in_dashboard]
4697
+
4698
+ when 'internal-links', 'orphans', 'links-audit'
4699
+ handle_internal_links_command(target, options)
4700
+ exit 0 unless options[:in_dashboard]
4701
+ return if options[:in_dashboard]
4702
+
4703
+ when 'schema', 'rich-snippets', 'ld-json'
4704
+ handle_schema_command(target, extra, options)
4705
+ exit 0 unless options[:in_dashboard]
4706
+ return if options[:in_dashboard]
4707
+
4708
+ when 'llms', 'ai-ready'
4709
+ handle_llms_command(target, extra, options)
4710
+ exit 0 unless options[:in_dashboard]
4711
+ return if options[:in_dashboard]
4712
+
4713
+ when 'preview', 'serp-preview', 'social-preview'
4714
+ handle_preview_command(target, options)
4715
+ exit 0 unless options[:in_dashboard]
4716
+ return if options[:in_dashboard]
4717
+
4718
+ when 'trace', 'redirects', 'hops'
4719
+ handle_trace_command(target, options)
4720
+ exit 0 unless options[:in_dashboard]
4721
+ return if options[:in_dashboard]
4722
+
4723
+ when 'robots', 'robots-txt'
4724
+ handle_robots_command(target, extra, options)
4725
+ exit 0 unless options[:in_dashboard]
4726
+ return if options[:in_dashboard]
4727
+
4728
+ when 'backlinks', 'links'
4729
+ handle_backlinks_command(target, extra, options)
4730
+ exit 0 unless options[:in_dashboard]
4731
+ return if options[:in_dashboard]
4732
+
4733
+
2916
4734
  when 'trends', 'tr', 'google-trends', 'gtrends', 't'
2917
4735
  if target.nil? || target.strip.empty?
2918
4736
  # No target query given: pass through to GSC period decay/trends below
@@ -6055,7 +7873,6 @@ end
6055
7873
  end
6056
7874
 
6057
7875
  # Validate syntax of downloaded script
6058
- require 'tempfile'
6059
7876
  temp = Tempfile.new(['gsc-update', '.rb'])
6060
7877
  temp.write(remote_script)
6061
7878
  temp.close
@@ -7916,7 +9733,6 @@ def self.handle_interactive_shell(options = {})
7916
9733
  end
7917
9734
 
7918
9735
  # Shellwords parse
7919
- require 'shellwords'
7920
9736
  cmd_args = Shellwords.split(input) rescue input.split
7921
9737
  first = cmd_args[0].downcase
7922
9738
 
@@ -8116,6 +9932,7 @@ def self.print_commands_help
8116
9932
  end
8117
9933
  end
8118
9934
  end
9935
+ # --- End cli.rb ---
8119
9936
 
8120
9937
  # Top-level aliases for backwards compatibility
8121
9938
  GoogleTrends = GSC::GoogleTrends unless defined?(GoogleTrends)
@@ -8124,5 +9941,19 @@ KeywordsEverywhere = GSC::KeywordsEverywhere unless defined?(KeywordsEverywhere)
8124
9941
  Prompts = GSC::Prompts unless defined?(Prompts)
8125
9942
  PageAnalyzer = GSC::PageAnalyzer unless defined?(PageAnalyzer)
8126
9943
  SiteCrawler = GSC::SiteCrawler unless defined?(SiteCrawler)
8127
-
8128
- GSC::CLI.start(ARGV)
9944
+ GoogleSuggest = GSC::GoogleSuggest unless defined?(GoogleSuggest)
9945
+ OpenPageRank = GSC::OpenPageRank unless defined?(OpenPageRank)
9946
+ PageSpeed = GSC::PageSpeed unless defined?(PageSpeed)
9947
+ PageComparator = GSC::PageComparator unless defined?(PageComparator)
9948
+ ContentGap = GSC::ContentGap unless defined?(ContentGap)
9949
+ InternalLinks = GSC::InternalLinks unless defined?(InternalLinks)
9950
+ SchemaValidator = GSC::SchemaValidator unless defined?(SchemaValidator)
9951
+ LlmsGenerator = GSC::LlmsGenerator unless defined?(LlmsGenerator)
9952
+ SerpPreview = GSC::SerpPreview unless defined?(SerpPreview)
9953
+ NetworkTracer = GSC::NetworkTracer unless defined?(NetworkTracer)
9954
+ RobotsChecker = GSC::RobotsChecker unless defined?(RobotsChecker)
9955
+ BacklinksManager = GSC::BacklinksManager unless defined?(BacklinksManager)
9956
+
9957
+ if __FILE__ == $PROGRAM_NAME
9958
+ GSC::CLI.start(ARGV)
9959
+ end