gsc-cli 2.0.2 → 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.
- checksums.yaml +4 -4
- data/AUTH.md +205 -0
- data/FUNDING.md +120 -0
- data/README.md +171 -3
- data/bin/gsc +2028 -197
- data/dist/gsc +167 -5
- data/lib/gsc/backlinks_manager.rb +96 -0
- data/lib/gsc/cli.rb +156 -3
- data/lib/gsc/cli_advanced.rb +570 -0
- data/lib/gsc/config.rb +9 -0
- data/lib/gsc/content_gap.rb +112 -0
- data/lib/gsc/google_suggest.rb +109 -0
- data/lib/gsc/internal_links.rb +132 -0
- data/lib/gsc/llms_generator.rb +104 -0
- data/lib/gsc/network_tracer.rb +86 -0
- data/lib/gsc/open_page_rank.rb +72 -0
- data/lib/gsc/page_comparator.rb +108 -0
- data/lib/gsc/page_speed.rb +110 -0
- data/lib/gsc/robots_checker.rb +83 -0
- data/lib/gsc/schema_validator.rb +122 -0
- data/lib/gsc/serp_preview.rb +66 -0
- data/lib/gsc/version.rb +1 -1
- data/lib/gsc.rb +26 -0
- metadata +17 -2
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
|
|
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.2'
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
# ---
|
|
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
|
-
#
|
|
2181
|
-
|
|
2182
|
-
|
|
2183
|
-
|
|
2184
|
-
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
2188
|
-
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
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
|
-
|
|
2199
|
-
|
|
2200
|
-
|
|
2201
|
-
|
|
2202
|
-
|
|
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
|
-
|
|
2205
|
-
|
|
2206
|
-
|
|
2207
|
-
|
|
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
|
-
|
|
2211
|
-
|
|
2212
|
-
md << ""
|
|
3891
|
+
puts ""
|
|
3892
|
+
end
|
|
2213
3893
|
|
|
2214
|
-
|
|
2215
|
-
|
|
2216
|
-
|
|
2217
|
-
|
|
2218
|
-
|
|
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
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
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
|
-
|
|
2227
|
-
md << "---"
|
|
2228
|
-
md << ""
|
|
3928
|
+
end
|
|
2229
3929
|
|
|
2230
|
-
|
|
2231
|
-
|
|
2232
|
-
|
|
2233
|
-
|
|
2234
|
-
|
|
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
|
-
|
|
2244
|
-
|
|
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
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
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
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
2255
|
-
|
|
2256
|
-
|
|
2257
|
-
|
|
2258
|
-
|
|
2259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
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
|
-
|
|
2277
|
-
|
|
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
|
-
|
|
2281
|
-
|
|
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
|
-
|
|
2284
|
-
|
|
2285
|
-
|
|
2286
|
-
|
|
2287
|
-
|
|
2288
|
-
|
|
2289
|
-
|
|
2290
|
-
|
|
2291
|
-
|
|
2292
|
-
|
|
2293
|
-
|
|
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
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
|
|
2301
|
-
|
|
2302
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2316
|
-
|
|
2317
|
-
|
|
2318
|
-
|
|
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
|
-
|
|
2322
|
-
|
|
2323
|
-
|
|
2324
|
-
|
|
2325
|
-
|
|
2326
|
-
|
|
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
|
-
|
|
2329
|
-
|
|
2330
|
-
|
|
2331
|
-
|
|
2332
|
-
|
|
2333
|
-
|
|
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
|
-
|
|
2340
|
-
|
|
2341
|
-
|
|
2342
|
-
|
|
2343
|
-
|
|
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::
|
|
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
|