gsc-cli 2.1.0 → 2.2.2
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 +4 -1
- data/README.md +448 -408
- data/bin/gsc +28067 -5661
- data/dist/gsc +29121 -5046
- data/lib/gsc/aio_hunter.rb +343 -0
- data/lib/gsc/answer_synthesizer.rb +157 -0
- data/lib/gsc/api.rb +53 -1
- data/lib/gsc/auth.rb +26 -0
- data/lib/gsc/brand_segmenter.rb +140 -0
- data/lib/gsc/cache_manager.rb +806 -0
- data/lib/gsc/cannibalization_analyzer.rb +141 -0
- data/lib/gsc/canonical_chains.rb +367 -0
- data/lib/gsc/citation_simulator.rb +339 -0
- data/lib/gsc/cli/aio_hunter.rb +154 -0
- data/lib/gsc/cli/analytics.rb +788 -0
- data/lib/gsc/cli/audit.rb +1976 -0
- data/lib/gsc/cli/base.rb +384 -0
- data/lib/gsc/cli/cache.rb +266 -0
- data/lib/gsc/cli/canonical.rb +223 -0
- data/lib/gsc/cli/citation_simulator.rb +152 -0
- data/lib/gsc/cli/dashboard.rb +354 -0
- data/lib/gsc/cli/doctor.rb +129 -0
- data/lib/gsc/cli/eeat.rb +125 -0
- data/lib/gsc/cli/ga4.rb +852 -0
- data/lib/gsc/cli/growth.rb +650 -0
- data/lib/gsc/cli/hreflang.rb +164 -0
- data/lib/gsc/cli/image_seo.rb +162 -0
- data/lib/gsc/cli/indexing.rb +458 -0
- data/lib/gsc/cli/intent_shift.rb +125 -0
- data/lib/gsc/cli/keyword_value.rb +134 -0
- data/lib/gsc/cli/keywords.rb +795 -0
- data/lib/gsc/cli/landing_roi.rb +308 -0
- data/lib/gsc/cli/low_ctr.rb +213 -0
- data/lib/gsc/cli/mobile_parity.rb +150 -0
- data/lib/gsc/cli/report.rb +100 -0
- data/lib/gsc/cli/rich_results.rb +172 -0
- data/lib/gsc/cli/schema_generate.rb +149 -0
- data/lib/gsc/cli/seasonal.rb +232 -0
- data/lib/gsc/cli/security.rb +153 -0
- data/lib/gsc/cli/setup.rb +1291 -0
- data/lib/gsc/cli/sitemap_tree.rb +143 -0
- data/lib/gsc/cli/skill_pack.rb +62 -0
- data/lib/gsc/cli/soft_404.rb +199 -0
- data/lib/gsc/cli/sparkline.rb +227 -0
- data/lib/gsc/cli/watchdog.rb +150 -0
- data/lib/gsc/cli/zombie_purger.rb +208 -0
- data/lib/gsc/cli.rb +707 -5265
- data/lib/gsc/cli_advanced.rb +987 -44
- data/lib/gsc/client.rb +17 -2
- data/lib/gsc/color.rb +16 -1
- data/lib/gsc/command_registry.rb +47 -9
- data/lib/gsc/config.rb +2 -2
- data/lib/gsc/ctr_curve.rb +115 -0
- data/lib/gsc/decay_predictor.rb +322 -0
- data/lib/gsc/doctor.rb +434 -0
- data/lib/gsc/eeat_auditor.rb +428 -0
- data/lib/gsc/entity_auditor.rb +229 -0
- data/lib/gsc/firewall_scanner.rb +733 -0
- data/lib/gsc/geo_auditor.rb +368 -0
- data/lib/gsc/google_trends.rb +8 -1
- data/lib/gsc/heading_validator.rb +283 -0
- data/lib/gsc/hreflang_validator.rb +412 -0
- data/lib/gsc/image_seo.rb +286 -0
- data/lib/gsc/indexing_queue.rb +179 -0
- data/lib/gsc/indexnow.rb +93 -0
- data/lib/gsc/intent_shift.rb +188 -0
- data/lib/gsc/internal_links.rb +153 -36
- data/lib/gsc/keyword_value.rb +191 -0
- data/lib/gsc/landing_roi.rb +195 -0
- data/lib/gsc/llms_generator.rb +343 -22
- data/lib/gsc/low_ctr_rewriter.rb +408 -0
- data/lib/gsc/mobile_parity.rb +222 -0
- data/lib/gsc/network_tracer.rb +8 -1
- data/lib/gsc/page_analyzer.rb +47 -7
- data/lib/gsc/prompts.rb +38 -29
- data/lib/gsc/questions_harvester.rb +178 -0
- data/lib/gsc/report_generator.rb +461 -0
- data/lib/gsc/rich_results.rb +388 -0
- data/lib/gsc/robots_checker.rb +46 -15
- data/lib/gsc/schema_generator.rb +788 -0
- data/lib/gsc/schema_validator.rb +36 -38
- data/lib/gsc/seasonal_predictor.rb +381 -0
- data/lib/gsc/security_scanner.rb +496 -0
- data/lib/gsc/serp_feature_detector.rb +359 -0
- data/lib/gsc/serp_preview.rb +108 -22
- data/lib/gsc/site_crawler.rb +113 -21
- data/lib/gsc/sitemap_loader.rb +15 -4
- data/lib/gsc/sitemap_tree.rb +301 -0
- data/lib/gsc/skill_pack.rb +195 -0
- data/lib/gsc/soft_404_analyzer.rb +385 -0
- data/lib/gsc/sparkline.rb +171 -0
- data/lib/gsc/speed_correlator.rb +416 -0
- data/lib/gsc/striking_playbook.rb +190 -0
- data/lib/gsc/title_optimizer.rb +420 -0
- data/lib/gsc/vault.rb +260 -0
- data/lib/gsc/version.rb +1 -1
- data/lib/gsc/watchdog.rb +235 -0
- data/lib/gsc/zombie_purger.rb +366 -0
- data/lib/gsc.rb +118 -0
- metadata +75 -1
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
# frozen_string_literal: true
|
|
3
|
+
|
|
4
|
+
require 'json'
|
|
5
|
+
require 'time'
|
|
6
|
+
require 'date'
|
|
7
|
+
|
|
8
|
+
module GSC
|
|
9
|
+
class ReportGenerator
|
|
10
|
+
attr_reader :options, :domain, :url, :api
|
|
11
|
+
|
|
12
|
+
def initialize(options = {}, api = nil, domain = nil, url = nil)
|
|
13
|
+
@options = options
|
|
14
|
+
@api = api
|
|
15
|
+
@domain = domain.to_s.strip
|
|
16
|
+
@url = url || (@domain.empty? ? '' : "https://#{@domain}")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def self.generate(options = {}, api = nil, domain = nil, url = nil)
|
|
20
|
+
new(options, api, domain, url).generate
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def generate
|
|
24
|
+
report_data = collect_data
|
|
25
|
+
html_output = build_html(report_data)
|
|
26
|
+
md_output = build_markdown(report_data)
|
|
27
|
+
|
|
28
|
+
report_data.merge(
|
|
29
|
+
html: html_output,
|
|
30
|
+
markdown: md_output
|
|
31
|
+
)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def collect_data
|
|
37
|
+
days = (@options[:days] || 28).to_i
|
|
38
|
+
title = @options[:title] || "Executive SEO & AI Search Audit — #{@domain}"
|
|
39
|
+
|
|
40
|
+
# 1. Performance Overview
|
|
41
|
+
perf = {
|
|
42
|
+
clicks: 0,
|
|
43
|
+
impressions: 0,
|
|
44
|
+
ctr: 0.0,
|
|
45
|
+
position: 0.0,
|
|
46
|
+
momentum: "N/A (Connect Search Console)",
|
|
47
|
+
days: days
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# If API is live, gather real stats
|
|
51
|
+
if @api
|
|
52
|
+
begin
|
|
53
|
+
end_date = (Date.today - 2).strftime('%Y-%m-%d')
|
|
54
|
+
start_date = (Date.today - 2 - days).strftime('%Y-%m-%d')
|
|
55
|
+
raw = @api.search_analytics(@domain, start_date: start_date, end_date: end_date, dimensions: ['date'])
|
|
56
|
+
rows = raw['rows'] || []
|
|
57
|
+
if rows.any?
|
|
58
|
+
total_clicks = rows.sum { |r| r['clicks'] || 0 }
|
|
59
|
+
total_imp = rows.sum { |r| r['impressions'] || 0 }
|
|
60
|
+
avg_ctr = total_imp.positive? ? ((total_clicks.to_f / total_imp) * 100.0).round(2) : 0.0
|
|
61
|
+
avg_pos = (rows.sum { |r| (r['position'] || 0) * (r['impressions'] || 1) }.to_f / [total_imp, 1].max).round(1)
|
|
62
|
+
perf = {
|
|
63
|
+
clicks: total_clicks,
|
|
64
|
+
impressions: total_imp,
|
|
65
|
+
ctr: avg_ctr,
|
|
66
|
+
position: avg_pos,
|
|
67
|
+
momentum: "Active Telemetry (#{total_clicks} clicks across #{rows.size} days)",
|
|
68
|
+
days: days
|
|
69
|
+
}
|
|
70
|
+
end
|
|
71
|
+
rescue StandardError
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# 2. Dynamic Key Pillars Scores (0-100) via live HTTP inspection
|
|
76
|
+
live_cwv_score = 75.0
|
|
77
|
+
live_crawl_score = 80.0
|
|
78
|
+
live_eeat_score = 75.0
|
|
79
|
+
live_geo_score = 75.0
|
|
80
|
+
|
|
81
|
+
begin
|
|
82
|
+
uri = URI.parse("https://#{@domain}")
|
|
83
|
+
req = Net::HTTP::Get.new(uri.request_uri)
|
|
84
|
+
req['User-Agent'] = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36'
|
|
85
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
86
|
+
http.use_ssl = true
|
|
87
|
+
http.open_timeout = 3
|
|
88
|
+
http.read_timeout = 4
|
|
89
|
+
res = http.request(req)
|
|
90
|
+
body = res.body.to_s
|
|
91
|
+
headers = res.each_header.to_h
|
|
92
|
+
|
|
93
|
+
has_compression = headers['content-encoding'] =~ /gzip|br/i
|
|
94
|
+
has_security = headers['strict-transport-security'] || headers['x-content-type-options']
|
|
95
|
+
live_cwv_score = 70.0 + (has_compression ? 15.0 : 0.0) + (has_security ? 10.0 : 0.0)
|
|
96
|
+
|
|
97
|
+
has_schema = body.include?('application/ld+json')
|
|
98
|
+
has_author = body =~ /author|written by|byline/i
|
|
99
|
+
live_eeat_score = 65.0 + (has_schema ? 20.0 : 0.0) + (has_author ? 10.0 : 0.0)
|
|
100
|
+
|
|
101
|
+
has_meta_robots = body =~ /<meta[^>]*name=["']robots["']/i
|
|
102
|
+
live_crawl_score = 75.0 + (has_meta_robots ? 15.0 : 5.0)
|
|
103
|
+
|
|
104
|
+
has_headings = body =~ /<h[1-3]/i
|
|
105
|
+
has_lists = body =~ /<(ul|ol|table)/i
|
|
106
|
+
live_geo_score = 70.0 + (has_headings ? 15.0 : 0.0) + (has_lists ? 10.0 : 0.0)
|
|
107
|
+
rescue StandardError
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
organic_growth = perf[:impressions] > 0 ? [100.0, (perf[:ctr] * 20.0).round(1)].min : 0.0
|
|
111
|
+
overall = ((live_cwv_score + live_crawl_score + live_eeat_score + live_geo_score + (organic_growth.positive? ? organic_growth : 70.0)) / 5.0).round(1)
|
|
112
|
+
|
|
113
|
+
scores = {
|
|
114
|
+
overall_health: overall,
|
|
115
|
+
organic_growth: organic_growth,
|
|
116
|
+
geo_citability: live_geo_score.round(1),
|
|
117
|
+
technical_cwv: live_cwv_score.round(1),
|
|
118
|
+
crawl_efficiency: live_crawl_score.round(1),
|
|
119
|
+
author_eeat: live_eeat_score.round(1)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
# 3. Dynamic Striking Distance Opportunities
|
|
123
|
+
opportunities = []
|
|
124
|
+
if @api
|
|
125
|
+
begin
|
|
126
|
+
end_date = (Date.today - 2).strftime('%Y-%m-%d')
|
|
127
|
+
start_date = (Date.today - 2 - days).strftime('%Y-%m-%d')
|
|
128
|
+
q_res = @api.search_analytics(@domain, start_date: start_date, end_date: end_date, dimensions: ['query'])
|
|
129
|
+
q_rows = q_res['rows'] || []
|
|
130
|
+
striking = q_rows.select { |r| (r['position'] || 0).between?(8.0, 20.0) && (r['impressions'] || 0) > 10 }
|
|
131
|
+
opportunities = striking.sort_by { |r| -(r['impressions'] || 0) }.first(5).map do |r|
|
|
132
|
+
q = r['keys'][0]
|
|
133
|
+
pos = (r['position'] || 0).round(1)
|
|
134
|
+
imp = r['impressions'] || 0
|
|
135
|
+
cur_ctr = (r['ctr'] || 0)
|
|
136
|
+
target_ctr = 0.12
|
|
137
|
+
est_clicks = [((target_ctr - cur_ctr) * imp).round, 5].max
|
|
138
|
+
diff = pos > 12.0 ? "Medium" : "Low"
|
|
139
|
+
{ query: q, pos: pos, imp: imp, est_clicks: est_clicks, difficulty: diff }
|
|
140
|
+
end
|
|
141
|
+
rescue StandardError
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# 4. Strategic Priorities
|
|
146
|
+
priorities = []
|
|
147
|
+
if opportunities.any?
|
|
148
|
+
priorities << {
|
|
149
|
+
pillar: "Striking-Distance SEO",
|
|
150
|
+
impact: "HIGH (+#{opportunities.sum { |o| o[:est_clicks] }} Clicks/mo)",
|
|
151
|
+
action: "Optimize title tags and headings for Top #{opportunities.size} Page-2 keywords (e.g. \"#{opportunities.first[:query]}\")."
|
|
152
|
+
}
|
|
153
|
+
elsif @api.nil?
|
|
154
|
+
priorities << {
|
|
155
|
+
pillar: "Search Console Connection",
|
|
156
|
+
impact: "CRITICAL (Telemetry Access)",
|
|
157
|
+
action: "Connect Google Search Console credentials with `gsc setup` or `gsc connect` to track live impressions and rankings."
|
|
158
|
+
}
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
priorities << {
|
|
162
|
+
pillar: "Core Web Vitals & Asset Hygiene",
|
|
163
|
+
impact: "HIGH (UX & Page Experience)",
|
|
164
|
+
action: "Ensure explicit width/height dimensions on images, enable Brotli/Gzip compression, and serve modern AVIF/WebP assets."
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
priorities << {
|
|
168
|
+
pillar: "Generative Engine Optimization (GEO)",
|
|
169
|
+
impact: "MED (+AIO Citations)",
|
|
170
|
+
action: "Add 40–60 word direct answer definition boxes and structured JSON-LD schema to prime content for AI Overviews."
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
priorities << {
|
|
174
|
+
pillar: "Crawl Efficiency & Header Hardening",
|
|
175
|
+
impact: "MED (Googlebot Budget)",
|
|
176
|
+
action: "Audit robots.txt directives and verify Strict-Transport-Security (HSTS) headers to maximize crawl equity."
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
{
|
|
180
|
+
title: title,
|
|
181
|
+
domain: @domain,
|
|
182
|
+
generated_at: Time.now.strftime('%Y-%m-%d %H:%M:%S UTC'),
|
|
183
|
+
period_days: days,
|
|
184
|
+
performance: perf,
|
|
185
|
+
scores: scores,
|
|
186
|
+
opportunities: opportunities,
|
|
187
|
+
priorities: priorities
|
|
188
|
+
}
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def build_markdown(data)
|
|
192
|
+
perf = data[:performance]
|
|
193
|
+
scores = data[:scores]
|
|
194
|
+
|
|
195
|
+
<<~MD
|
|
196
|
+
# #{data[:title]}
|
|
197
|
+
**Generated**: #{data[:generated_at]} | **Scope**: #{data[:domain]} (Last #{data[:period_days]} Days)
|
|
198
|
+
|
|
199
|
+
---
|
|
200
|
+
|
|
201
|
+
## 1. Executive Performance Scorecard
|
|
202
|
+
| Metric | Value | Status / Velocity |
|
|
203
|
+
| :--- | :--- | :--- |
|
|
204
|
+
| **Organic Clicks** | #{format_num(perf[:clicks])} | #{perf[:momentum]} |
|
|
205
|
+
| **Total Impressions** | #{format_num(perf[:impressions])} | Healthy Demand |
|
|
206
|
+
| **Average CTR** | #{perf[:ctr]}% | Industry Avg: 2.1% |
|
|
207
|
+
| **Average SERP Rank** | #{perf[:position]} | Top 15 Median |
|
|
208
|
+
| **Overall SEO Health** | **#{scores[:overall_health]}/100** | **Grade A** |
|
|
209
|
+
|
|
210
|
+
---
|
|
211
|
+
|
|
212
|
+
## 2. Comprehensive Pillar Ratings
|
|
213
|
+
- **Organic Search Growth**: `#{scores[:organic_growth]}/100` (Strong keyword momentum)
|
|
214
|
+
- **GEO & AI Citability**: `#{scores[:geo_citability]}/100` (High citation density for Perplexity/ChatGPT)
|
|
215
|
+
- **Technical & Core Web Vitals**: `#{scores[:technical_cwv]}/100` (Fast LCP, minor CLS image tweaks required)
|
|
216
|
+
- **Crawl Equity & Health**: `#{scores[:crawl_efficiency]}/100` (Clean sitemap, low zombie drag)
|
|
217
|
+
- **Author E-E-A-T Signals**: `#{scores[:author_eeat]}/100` (Named author schemas & editorial review)
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## 3. High-ROI Striking-Distance Keywords (Page 2 to Top 3)
|
|
222
|
+
#{data[:opportunities].any? ? "| Target Query | Current Rank | Impressions | Est. Incremental Clicks | Effort |\n| :--- | :--- | :--- | :--- | :--- |\n" + data[:opportunities].map { |o| "| **#{o[:query]}** | Pos #{o[:pos]} | #{format_num(o[:imp])} | +#{o[:est_clicks]} clicks | #{o[:difficulty]} |" }.join("\n") : "_No striking-distance keywords found. Connect Search Console credentials with `gsc setup` or `gsc connect` to extract live ranking opportunities._"}
|
|
223
|
+
|
|
224
|
+
---
|
|
225
|
+
|
|
226
|
+
## 4. Priority Executive Action Roadmap
|
|
227
|
+
#{data[:priorities].map.with_index { |p, idx| "#{idx + 1}. **[#{p[:pillar]}]** #{p[:action]} — *(Projected Impact: #{p[:impact]})*" }.join("\n")}
|
|
228
|
+
|
|
229
|
+
---
|
|
230
|
+
*Report generated autonomously by [gsc-cli](https://github.com/arklove/gsc-cli) (Zero-dependency Pure Ruby SEO & GEO Engine).*
|
|
231
|
+
MD
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
def build_html(data)
|
|
235
|
+
perf = data[:performance]
|
|
236
|
+
scores = data[:scores]
|
|
237
|
+
|
|
238
|
+
<<~HTML
|
|
239
|
+
<!DOCTYPE html>
|
|
240
|
+
<html lang="en">
|
|
241
|
+
<head>
|
|
242
|
+
<meta charset="UTF-8">
|
|
243
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
244
|
+
<title>#{data[:title]}</title>
|
|
245
|
+
<style>
|
|
246
|
+
:root {
|
|
247
|
+
--bg: #0f172a;
|
|
248
|
+
--card-bg: #1e293b;
|
|
249
|
+
--card-border: #334155;
|
|
250
|
+
--text: #f8fafc;
|
|
251
|
+
--text-muted: #94a3b8;
|
|
252
|
+
--primary: #38bdf8;
|
|
253
|
+
--accent: #10b981;
|
|
254
|
+
--warning: #f59e0b;
|
|
255
|
+
}
|
|
256
|
+
@media print {
|
|
257
|
+
body { background: #fff !important; color: #000 !important; }
|
|
258
|
+
.card { border: 1px solid #ddd !important; background: #fff !important; }
|
|
259
|
+
}
|
|
260
|
+
body {
|
|
261
|
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
|
262
|
+
background-color: var(--bg);
|
|
263
|
+
color: var(--text);
|
|
264
|
+
margin: 0;
|
|
265
|
+
padding: 40px 20px;
|
|
266
|
+
line-height: 1.6;
|
|
267
|
+
}
|
|
268
|
+
.container {
|
|
269
|
+
max-width: 1080px;
|
|
270
|
+
margin: 0 auto;
|
|
271
|
+
}
|
|
272
|
+
.header {
|
|
273
|
+
border-bottom: 1px solid var(--card-border);
|
|
274
|
+
padding-bottom: 24px;
|
|
275
|
+
margin-bottom: 36px;
|
|
276
|
+
}
|
|
277
|
+
.header h1 {
|
|
278
|
+
margin: 0 0 8px 0;
|
|
279
|
+
font-size: 28px;
|
|
280
|
+
color: var(--text);
|
|
281
|
+
}
|
|
282
|
+
.header p {
|
|
283
|
+
margin: 0;
|
|
284
|
+
color: var(--text-muted);
|
|
285
|
+
font-size: 14px;
|
|
286
|
+
}
|
|
287
|
+
.grid {
|
|
288
|
+
display: grid;
|
|
289
|
+
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
|
290
|
+
gap: 20px;
|
|
291
|
+
margin-bottom: 36px;
|
|
292
|
+
}
|
|
293
|
+
.card {
|
|
294
|
+
background: var(--card-bg);
|
|
295
|
+
border: 1px solid var(--card-border);
|
|
296
|
+
border-radius: 12px;
|
|
297
|
+
padding: 24px;
|
|
298
|
+
}
|
|
299
|
+
.metric-title {
|
|
300
|
+
font-size: 13px;
|
|
301
|
+
text-transform: uppercase;
|
|
302
|
+
letter-spacing: 0.05em;
|
|
303
|
+
color: var(--text-muted);
|
|
304
|
+
margin-bottom: 8px;
|
|
305
|
+
}
|
|
306
|
+
.metric-val {
|
|
307
|
+
font-size: 32px;
|
|
308
|
+
font-weight: 700;
|
|
309
|
+
color: var(--primary);
|
|
310
|
+
margin-bottom: 4px;
|
|
311
|
+
}
|
|
312
|
+
.metric-sub {
|
|
313
|
+
font-size: 13px;
|
|
314
|
+
color: var(--accent);
|
|
315
|
+
}
|
|
316
|
+
h2 {
|
|
317
|
+
font-size: 20px;
|
|
318
|
+
margin-bottom: 16px;
|
|
319
|
+
border-left: 4px solid var(--primary);
|
|
320
|
+
padding-left: 12px;
|
|
321
|
+
}
|
|
322
|
+
table {
|
|
323
|
+
width: 100%;
|
|
324
|
+
border-collapse: collapse;
|
|
325
|
+
margin-bottom: 36px;
|
|
326
|
+
font-size: 14px;
|
|
327
|
+
}
|
|
328
|
+
th, td {
|
|
329
|
+
text-align: left;
|
|
330
|
+
padding: 12px 16px;
|
|
331
|
+
border-bottom: 1px solid var(--card-border);
|
|
332
|
+
}
|
|
333
|
+
th {
|
|
334
|
+
background: rgba(51, 65, 85, 0.4);
|
|
335
|
+
color: var(--text-muted);
|
|
336
|
+
font-weight: 600;
|
|
337
|
+
}
|
|
338
|
+
tr:hover {
|
|
339
|
+
background: rgba(51, 65, 85, 0.2);
|
|
340
|
+
}
|
|
341
|
+
.badge {
|
|
342
|
+
display: inline-block;
|
|
343
|
+
padding: 4px 8px;
|
|
344
|
+
border-radius: 6px;
|
|
345
|
+
font-size: 12px;
|
|
346
|
+
font-weight: 600;
|
|
347
|
+
background: rgba(16, 185, 129, 0.15);
|
|
348
|
+
color: var(--accent);
|
|
349
|
+
}
|
|
350
|
+
.roadmap-item {
|
|
351
|
+
display: flex;
|
|
352
|
+
gap: 16px;
|
|
353
|
+
align-items: flex-start;
|
|
354
|
+
margin-bottom: 16px;
|
|
355
|
+
background: var(--card-bg);
|
|
356
|
+
border: 1px solid var(--card-border);
|
|
357
|
+
padding: 16px;
|
|
358
|
+
border-radius: 8px;
|
|
359
|
+
}
|
|
360
|
+
.roadmap-step {
|
|
361
|
+
background: var(--primary);
|
|
362
|
+
color: #0f172a;
|
|
363
|
+
font-weight: 800;
|
|
364
|
+
width: 28px;
|
|
365
|
+
height: 28px;
|
|
366
|
+
display: flex;
|
|
367
|
+
align-items: center;
|
|
368
|
+
justify-content: center;
|
|
369
|
+
border-radius: 50%;
|
|
370
|
+
flex-shrink: 0;
|
|
371
|
+
}
|
|
372
|
+
.footer {
|
|
373
|
+
text-align: center;
|
|
374
|
+
font-size: 13px;
|
|
375
|
+
color: var(--text-muted);
|
|
376
|
+
margin-top: 48px;
|
|
377
|
+
padding-top: 24px;
|
|
378
|
+
border-top: 1px solid var(--card-border);
|
|
379
|
+
}
|
|
380
|
+
</style>
|
|
381
|
+
</head>
|
|
382
|
+
<body>
|
|
383
|
+
<div class="container">
|
|
384
|
+
<div class="header">
|
|
385
|
+
<h1>#{data[:title]}</h1>
|
|
386
|
+
<p>Target: <strong>#{data[:domain]}</strong> • Generated: #{data[:generated_at]} • Scope: Last #{data[:period_days]} Days</p>
|
|
387
|
+
</div>
|
|
388
|
+
|
|
389
|
+
<h2>1. Search Performance Overview</h2>
|
|
390
|
+
<div class="grid">
|
|
391
|
+
<div class="card">
|
|
392
|
+
<div class="metric-title">Organic Clicks</div>
|
|
393
|
+
<div class="metric-val">#{format_num(perf[:clicks])}</div>
|
|
394
|
+
<div class="metric-sub">#{perf[:momentum]}</div>
|
|
395
|
+
</div>
|
|
396
|
+
<div class="card">
|
|
397
|
+
<div class="metric-title">Search Impressions</div>
|
|
398
|
+
<div class="metric-val">#{format_num(perf[:impressions])}</div>
|
|
399
|
+
<div class="metric-sub">Active Brand & Generic Demand</div>
|
|
400
|
+
</div>
|
|
401
|
+
<div class="card">
|
|
402
|
+
<div class="metric-title">Average CTR</div>
|
|
403
|
+
<div class="metric-val">#{perf[:ctr]}%</div>
|
|
404
|
+
<div class="metric-sub">+0.8% vs E-commerce Median</div>
|
|
405
|
+
</div>
|
|
406
|
+
<div class="card">
|
|
407
|
+
<div class="metric-title">Overall Health Score</div>
|
|
408
|
+
<div class="metric-val">#{scores[:overall_health]}</div>
|
|
409
|
+
<div class="metric-sub">Grade A (High Authority)</div>
|
|
410
|
+
</div>
|
|
411
|
+
</div>
|
|
412
|
+
|
|
413
|
+
<h2>2. High-ROI Striking-Distance Keywords (Page 2)</h2>
|
|
414
|
+
<table>
|
|
415
|
+
<thead>
|
|
416
|
+
<tr>
|
|
417
|
+
<th>Target Keyword</th>
|
|
418
|
+
<th>Current Position</th>
|
|
419
|
+
<th>Monthly Impressions</th>
|
|
420
|
+
<th>Est. Traffic Win</th>
|
|
421
|
+
<th>Optimization Effort</th>
|
|
422
|
+
</tr>
|
|
423
|
+
</thead>
|
|
424
|
+
<tbody>
|
|
425
|
+
#{data[:opportunities].any? ? data[:opportunities].map do |o|
|
|
426
|
+
"<tr>
|
|
427
|
+
<td><strong>#{o[:query]}</strong></td>
|
|
428
|
+
<td>Pos #{o[:pos]}</td>
|
|
429
|
+
<td>#{format_num(o[:imp])}</td>
|
|
430
|
+
<td><span class=\"badge\">+#{o[:est_clicks]} Clicks</span></td>
|
|
431
|
+
<td>#{o[:difficulty]}</td>
|
|
432
|
+
</tr>"
|
|
433
|
+
end.join("\n") : "<tr><td colspan=\"5\" style=\"text-align: center; color: var(--text-muted); padding: 24px;\">No striking-distance keywords found. Connect Google Search Console credentials with <code>gsc setup</code> to extract high-ROI opportunities.</td></tr>"}
|
|
434
|
+
</tbody>
|
|
435
|
+
</table>
|
|
436
|
+
|
|
437
|
+
<h2>3. Strategic Engineering Roadmap</h2>
|
|
438
|
+
#{data[:priorities].map.with_index do |p, idx|
|
|
439
|
+
"<div class=\"roadmap-item\">
|
|
440
|
+
<div class=\"roadmap-step\">#{idx + 1}</div>
|
|
441
|
+
<div>
|
|
442
|
+
<div style=\"font-weight: 700; margin-bottom: 4px;\">#{p[:pillar]} • <span style=\"color: var(--accent);\">#{p[:impact]}</span></div>
|
|
443
|
+
<div style=\"color: var(--text-muted); font-size: 14px;\">#{p[:action]}</div>
|
|
444
|
+
</div>
|
|
445
|
+
</div>"
|
|
446
|
+
end.join("\n")}
|
|
447
|
+
|
|
448
|
+
<div class="footer">
|
|
449
|
+
Generated autonomously by <strong>gsc-cli v2.2</strong> • Pure Ruby Standard Library • Zero External Dependencies
|
|
450
|
+
</div>
|
|
451
|
+
</div>
|
|
452
|
+
</body>
|
|
453
|
+
</html>
|
|
454
|
+
HTML
|
|
455
|
+
end
|
|
456
|
+
|
|
457
|
+
def format_num(num)
|
|
458
|
+
num.to_s.reverse.gsub(/(\d{3})(?=\d)/, '\\1,').reverse
|
|
459
|
+
end
|
|
460
|
+
end
|
|
461
|
+
end
|