gsc-cli 2.1.0 → 2.2.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/README.md +410 -414
- 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
data/lib/gsc/client.rb
CHANGED
|
@@ -55,12 +55,27 @@ module GSC
|
|
|
55
55
|
# Transparent gzip decompression
|
|
56
56
|
raw_body = response.body
|
|
57
57
|
body_str = if response['content-encoding'] =~ /gzip/i && raw_body && !raw_body.empty?
|
|
58
|
-
|
|
58
|
+
begin
|
|
59
|
+
Zlib::GzipReader.new(StringIO.new(raw_body)).read
|
|
60
|
+
rescue Zlib::GzipFile::Error, Zlib::Error
|
|
61
|
+
raw_body
|
|
62
|
+
end
|
|
59
63
|
else
|
|
60
64
|
raw_body
|
|
61
65
|
end
|
|
62
66
|
|
|
63
|
-
|
|
67
|
+
body_str = body_str.to_s.dup.force_encoding('UTF-8').scrub if body_str
|
|
68
|
+
|
|
69
|
+
body = if body_str && !body_str.strip.empty?
|
|
70
|
+
begin
|
|
71
|
+
parsed = JSON.parse(body_str)
|
|
72
|
+
parsed.is_a?(Hash) || parsed.is_a?(Array) ? parsed : { 'value' => parsed }
|
|
73
|
+
rescue JSON::ParserError, Encoding::InvalidByteSequenceError, Encoding::UndefinedConversionError
|
|
74
|
+
{ 'error' => { 'message' => body_str }, 'raw' => body_str }
|
|
75
|
+
end
|
|
76
|
+
else
|
|
77
|
+
{}
|
|
78
|
+
end
|
|
64
79
|
|
|
65
80
|
{
|
|
66
81
|
ok: response.is_a?(Net::HTTPSuccess),
|
data/lib/gsc/color.rb
CHANGED
|
@@ -16,7 +16,22 @@ module GSC
|
|
|
16
16
|
GRAY = "\e[90m"
|
|
17
17
|
|
|
18
18
|
def self.c(text, *styles)
|
|
19
|
-
|
|
19
|
+
t = text.to_s.dup.force_encoding('UTF-8').scrub
|
|
20
|
+
"#{styles.join}#{t}#{RESET}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def self.cyan(str); c(str, CYAN); end
|
|
24
|
+
def self.yellow(str); c(str, YELLOW); end
|
|
25
|
+
def self.green(str); c(str, GREEN); end
|
|
26
|
+
def self.red(str); c(str, RED); end
|
|
27
|
+
def self.bold(str); c(str, BOLD); end
|
|
28
|
+
def self.gray(str); c(str, GRAY); end
|
|
29
|
+
def self.magenta(str); c(str, MAGENTA); end
|
|
30
|
+
def self.white(str); c(str, WHITE); end
|
|
31
|
+
def self.dim(str); c(str, DIM); end
|
|
32
|
+
|
|
33
|
+
def self.strip_ansi(str)
|
|
34
|
+
str.to_s.gsub(/\e\[[0-9;]*[mK]/, '')
|
|
20
35
|
end
|
|
21
36
|
end
|
|
22
37
|
end
|
data/lib/gsc/command_registry.rb
CHANGED
|
@@ -6,9 +6,9 @@ COMMAND_REGISTRY = [
|
|
|
6
6
|
{
|
|
7
7
|
category: "Google Trends & Keyword Demand",
|
|
8
8
|
commands: [
|
|
9
|
-
{ name: "ke <seed|file>", shortcut: "ke", desc: "Keywords Everywhere: Exact monthly volume, CPC, competition & GSC correlation", flags: ["--country", "--limit", "--json"] },
|
|
10
|
-
{ name: "ke-credits", shortcut: "ke-credits", desc: "Check remaining Keywords Everywhere account API credits", flags: ["--json"] },
|
|
11
|
-
{ name: "connect ke [key]", shortcut: "connect ke", desc: "Connect Keywords Everywhere API key and save to config.json", flags: [] },
|
|
9
|
+
{ name: "ke <seed|file>", shortcut: "ke", desc: "Keywords Everywhere: Exact monthly volume, CPC, competition & GSC correlation", flags: ["--country", "--limit", "--json"] },
|
|
10
|
+
{ name: "ke-credits", shortcut: "ke-credits", desc: "Check remaining Keywords Everywhere account API credits", flags: ["--json"] },
|
|
11
|
+
{ name: "connect ke [key]", shortcut: "connect ke", desc: "Connect Keywords Everywhere API key and save to config.json", flags: [] },
|
|
12
12
|
{ name: "trends <query>", shortcut: "tr", desc: "Live Google Trends: 5yr/1yr demand trajectory, velocity & breakout queries", flags: ["--geo", "--time", "--json"] },
|
|
13
13
|
{ name: "planner <seed>", shortcut: "kp", desc: "Free keyword planner: intent expansion & GSC ranking correlation", flags: ["--country", "--limit", "--json"] },
|
|
14
14
|
{ name: "planner-import <file>", shortcut: "pi", desc: "Import Google Ads CSV or Markdown table to score & rank opportunities", flags: ["--limit", "--json"] }
|
|
@@ -18,12 +18,18 @@ COMMAND_REGISTRY = [
|
|
|
18
18
|
category: "Search Console Intelligence",
|
|
19
19
|
commands: [
|
|
20
20
|
{ name: "performance", shortcut: "p", desc: "Executive dashboard: Clicks, Impressions, CTR, Position", flags: ["--days", "--limit", "--csv"] },
|
|
21
|
-
{ name: "top-queries", shortcut: "t", desc: "Top search queries, rankings, and CTR", flags: ["--days", "--limit", "-s", "--order", "--csv"] },
|
|
21
|
+
{ name: "top-queries", shortcut: "t", desc: "Top search queries, rankings, and CTR", flags: ["--days", "--limit", "-s", "--order", "--brand", "--non-brand", "--brand-name", "--csv"] },
|
|
22
|
+
{ name: "brand", shortcut: "b", desc: "Brand vs. non-brand search query segmentation & share", flags: ["--days", "--limit", "--brand-name", "--json"] },
|
|
23
|
+
{ name: "ctr-curve", shortcut: nil, desc: "Expected SERP CTR curve & click gain simulator", flags: ["--target-pos", "--min-imp", "--limit", "--days", "--csv"] },
|
|
22
24
|
{ name: "top-pages", shortcut: nil, desc: "Top landing pages driving organic search clicks", flags: ["--days", "--limit", "-s", "--order", "--csv"] },
|
|
23
25
|
{ name: "opportunities", shortcut: "o", desc: "Striking-distance queries (Pos 7-20) to push to Top 3", flags: ["--days", "--min-imp", "--min-pos", "--max-pos"] },
|
|
26
|
+
{ name: "strike", shortcut: "sp", desc: "Striking-distance tactical playbook: Title rewrites, heading recipes & internal link anchors (Pos 7-20)", flags: ["--days", "--min-imp", "--min-pos", "--max-pos", "--limit", "--csv", "--json"] },
|
|
24
27
|
{ name: "underperformers", shortcut: "u", desc: "High-ranking queries (Top 10) with low CTR (title tag wins)", flags: ["--days", "--min-imp"] },
|
|
28
|
+
{ name: "low-ctr [query/url]", shortcut: "lc", desc: "Low-CTR High-Impression Title Tag Rewriter: 3 SERP-safe hook titles (<560px), click hemorrhage & revenue recovery", flags: ["--min-imp", "--cpc", "--limit", "--brand", "--csv", "--json"] },
|
|
25
29
|
{ name: "cannibalization", shortcut: "c", desc: "Detect internal URLs competing for the same keywords", flags: ["--days", "--min-imp"] },
|
|
30
|
+
{ name: "questions-harvest [page]", shortcut: "qh", desc: "Harvest question queries from GSC and generate FAQPage JSON-LD schema", flags: ["--days", "--min-imp", "--limit", "--generate", "--csv", "--json"] },
|
|
26
31
|
{ name: "decay", shortcut: "d", desc: "Period-over-period decay detection (decaying vs surging)", flags: ["--compare"] },
|
|
32
|
+
{ name: "sparklines [query|page]", shortcut: "spark", desc: "Terminal ASCII Sparklines & Multi-Horizon Trajectory Graphs (Clicks, Imp, CTR, Pos)", flags: ["--days", "--metric", "--height", "--data", "--csv", "--json"] },
|
|
27
33
|
{ name: "trends", shortcut: nil, desc: "Compare current vs prior period search trends", flags: ["--compare"] },
|
|
28
34
|
{ name: "devices", shortcut: nil, desc: "Desktop vs Mobile vs Tablet search traffic share", flags: ["--days"] },
|
|
29
35
|
{ name: "countries", shortcut: nil, desc: "Geographic search demand by country", flags: ["--days", "--limit"] },
|
|
@@ -52,27 +58,59 @@ COMMAND_REGISTRY = [
|
|
|
52
58
|
commands: [
|
|
53
59
|
{ name: "inspect <url>", shortcut: nil, desc: "Live Google index check (coverage, canonical, date, robots.txt)", flags: ["-d"] },
|
|
54
60
|
{ name: "index <url>", shortcut: nil, desc: "Notify Googlebot to crawl/index URL immediately (URL_UPDATED)", flags: ["--dry-run"] },
|
|
61
|
+
{ name: "index-batch [action]", shortcut: "ib", desc: "Automated 200 URL/day quota queue manager (add, run, status, clear)", flags: ["--batch-size", "--dry-run", "--json"] },
|
|
55
62
|
{ name: "remove <url>", shortcut: nil, desc: "Notify Googlebot a page has been deleted (URL_DELETED)", flags: ["--dry-run"] },
|
|
56
63
|
{ name: "status <url>", shortcut: nil, desc: "Check Google Indexing API notification metadata", flags: [] },
|
|
57
64
|
{ name: "sitemaps-list", shortcut: nil, desc: "List registered XML sitemaps in Search Console", flags: ["-d"] },
|
|
58
65
|
{ name: "sitemaps-submit <url>", shortcut: nil, desc: "Submit/register XML sitemap with Search Console", flags: ["-d"] },
|
|
59
66
|
{ name: "index-sitemap <file/url>", shortcut: nil, desc: "Batch notify Googlebot to index all sitemap URLs", flags: ["--delay", "--dry-run"] },
|
|
60
|
-
{ name: "inspect-sitemap <file/url>", shortcut: nil, desc: "Bulk inspect indexation status for all sitemap URLs", flags: ["--delay"] }
|
|
67
|
+
{ name: "inspect-sitemap <file/url>", shortcut: nil, desc: "Bulk inspect indexation status for all sitemap URLs", flags: ["--delay"] },
|
|
68
|
+
{ name: "indexnow <url>", shortcut: "in", desc: "Instant indexing via IndexNow protocol (Bing, Yandex, Seznam, Naver)", flags: ["--key", "--json"] },
|
|
69
|
+
{ name: "indexnow-sitemap <sitemap>", shortcut: "ins", desc: "Batch submit all sitemap URLs to IndexNow", flags: ["--key", "--limit", "--json"] },
|
|
70
|
+
{ name: "connect indexnow [key]", shortcut: nil, desc: "Configure IndexNow API key and display verification file details", flags: [] }
|
|
61
71
|
]
|
|
62
72
|
},
|
|
63
73
|
{
|
|
64
74
|
category: "Health, Diagnostics & Setup",
|
|
65
75
|
commands: [
|
|
76
|
+
{ name: "geo [url]", shortcut: "aeo", desc: "Generative Engine Optimization: Audit AI bot access & citability score (ChatGPT, Perplexity, Claude)", flags: ["--json"] },
|
|
77
|
+
{ name: "cite-sim <url|file> [query]", shortcut: "cite", desc: "AI Search Citation Simulator: Emulates ChatGPT, Perplexity & Claude citations, verifiable quote extraction & CLS score", flags: ["--query", "--model", "--csv", "--json"] },
|
|
78
|
+
{ name: "answer <query>", shortcut: "ans", desc: "Synthesize high-citability 40-60w direct answers & info-gain snippets (Featured Snippets & Perplexity)", flags: ["--brand", "--type", "--generate", "--json"] },
|
|
79
|
+
{ name: "entity [url]", shortcut: "kg", desc: "Knowledge Graph entity & sameAs disambiguation audit (Wikidata, Wikipedia, LinkedIn)", flags: ["--generate", "--json"] },
|
|
80
|
+
{ name: "serp <query|url>", shortcut: "preview", desc: "Live SERP Feature Detector (AI Overviews, Snippets, PAA, Local) & Pixel Width Simulator", flags: ["--live", "--preview", "--title", "--desc", "--url", "--json"] },
|
|
81
|
+
{ name: "serp-features <query>", shortcut: "sf", desc: "Scan live Google SERP features, Zero-Click Threat index & Rich Snippet capture playbook", flags: ["--json"] },
|
|
66
82
|
{ name: "page <url|file>", shortcut: "pg", desc: "Detailed On-Page DOM + Off-Page GSC Performance Audit", flags: ["--check-links", "--json"] },
|
|
83
|
+
{ name: "headings <url|file>", shortcut: "ht", desc: "Heading hierarchy tree validator: H1-H6 nesting, depth, empty tags & health score", flags: ["--keyword", "--json"] },
|
|
67
84
|
{ name: "site-audit [sitemap]", shortcut: "crl", desc: "Full site crawl, broken links (404/500), image alts & AI report", flags: ["--check-links", "--report", "--json"] },
|
|
85
|
+
{ name: "orphans [sitemap|url]", shortcut: "il", desc: "Internal link equity & orphan page rescue: Detect 0-link pages, click depth & suggested rescue sources", flags: ["--limit", "--concurrency", "--csv", "--json"] },
|
|
86
|
+
{ name: "speed-correlate [url]", shortcut: "cwv", desc: "Correlate Core Web Vitals (LCP/CLS) directly with Search Console impressions & projected traffic surge", flags: ["--days", "--strategy", "-d", "--json"] },
|
|
87
|
+
{ name: "firewall [url]", shortcut: "ai-bots", desc: "Scan /robots.txt & edge WAF/Cloudflare bot challenges for ChatGPT, Claude & Perplexity", flags: ["--user-agent", "--json"] },
|
|
88
|
+
{ name: "security [url]", shortcut: "sec", desc: "Security Headers & Mixed Content Auditor: HSTS preload, CSP, clickjacking & SSL/TLS certificate expiry", flags: ["--json"] },
|
|
89
|
+
{ name: "landing-roi [url]", shortcut: "roi", desc: "Landing Page Economic ROI & Revenue Hemorrhage: Merge GSC clicks with GA4 bounce rates to calculate dollar revenue leaks", flags: ["--aov", "--conv-rate", "--benchmark", "--clicks", "--bounce", "--csv", "--json"] },
|
|
90
|
+
{ name: "schema-generate [url]", shortcut: "schema-gen", desc: "Structured Data Rich Snippet Generator: Auto-synthesizes valid Schema.org JSON-LD (Product, FAQ, HowTo, Article, Software) with Google validation", flags: ["--type", "--price", "--currency", "--rating", "--format", "--open", "--json"] },
|
|
91
|
+
{ name: "sitemap-tree [path|url]", shortcut: "smt", desc: "Sitemap Index Hierarchy & Coverage Auditor: Recursive sub-sitemap tree, Google 50k URL / 50MB compliance & GSC coverage ratio", flags: ["--csv", "--json"] },
|
|
92
|
+
{ name: "titles [sitemap|url]", shortcut: "title-opt", desc: "Batch audit title pixel width (>580px) & synthesize non-truncating alternatives", flags: ["--overflow-only", "--limit", "--concurrency", "--csv", "--json"] },
|
|
93
|
+
{ name: "image-seo <url|file>", shortcut: "img", desc: "Image SEO & Next-Gen Format Auditor: WebP/AVIF formats, missing alt text, explicit width/height for CLS & LCP preloading", flags: ["--check-size", "--limit", "--csv", "--json"] },
|
|
94
|
+
{ name: "hreflang-check <url|file>", shortcut: "hreflang", desc: "International Hreflang Reciprocity Validator: Bidirectional link graph, ISO 639-1 / 3166-1 codes & x-default fallback", flags: ["--no-reciprocity", "--csv", "--json"] },
|
|
95
|
+
{ name: "eeat <url|file>", shortcut: "author", desc: "Author E-E-A-T & Credential Auditor: Person schema, credentials, editorial reviews, dates & Knowledge Graph links", flags: ["--csv", "--json"] },
|
|
68
96
|
{ name: "audit", shortcut: "a", desc: "360-degree Comprehensive SEO & GA4 health audit", flags: ["--days", "-d"] },
|
|
69
|
-
{ name: "
|
|
70
|
-
{ name: "
|
|
97
|
+
{ name: "report [domain]", shortcut: "rep", desc: "Automated Executive SEO & AI Search Report Generator: Single-file HTML dashboard & Markdown reports", flags: ["--html", "--md", "--days", "--title", "--json"] },
|
|
98
|
+
{ name: "intent-shift [domain]", shortcut: "intent", desc: "Search Intent Drift & Landing Page Mismatch Tracker: Detect transactional/informational intent divergence & compute Portfolio Volatility Index", flags: ["--days", "--brand", "--csv", "--json"] },
|
|
99
|
+
{ name: "rich-results <url|file>", shortcut: "rich-test", desc: "Google Rich Results Testing & Validation Suite: Disqualifying errors, recommended enhancements, 1-click JSON-LD fixes & browser testing", flags: ["--open", "--patch", "--csv", "--json"] },
|
|
100
|
+
{ name: "watch [domain]", shortcut: "mon", desc: "Continuous SERP Watchdog & Rank Drift Monitor: Real-time rank drop alerts, CTR crash detection, webhooks & automated cron/launchd daemon", flags: ["--once", "--daemon", "--interval", "--threshold", "--webhook", "--slack", "--cron", "--launchd", "--systemd", "--csv", "--json"] },
|
|
101
|
+
{ name: "kw-value [domain]", shortcut: "kwval", desc: "Conversion-Weighted Keyword Opportunity Matrix: Calculate revenue per keyword, simulate Top 3 pipeline upside & prioritize high-buyer-intent terms", flags: ["--aov", "--conv-rate", "--margin", "--target-pos", "--days", "--csv", "--json"] },
|
|
102
|
+
{ name: "mobile-parity [domain]", shortcut: "mobile", desc: "Mobile vs. Desktop SERP Parity Auditor: Cross-device rank gaps, responsive suppression penalties & mobile CTR loss diagnosis", flags: ["--days", "--min-imp", "--gap-threshold", "--csv", "--json"] },
|
|
103
|
+
{ name: "aio-hunter [query|domain]", shortcut: "aio", desc: "Google AI Overview (AIO) Opportunity Hunter: Detects AIO SERP presence, extracts cited sources, quantifies citation gaps & generates snippet capture recipes", flags: ["--limit", "--min-imp", "--days", "--csv", "--json"] },
|
|
104
|
+
{ name: "skill-pack [dir]", shortcut: "agent-pack", desc: "Autonomous AI Agent Skill Packaging Engine: Synthesizes production SKILL.md, recipes.json & integration hooks for Antigravity, Claude Code & Cursor", flags: ["--target", "--dir", "--dry-run", "--json"] },
|
|
105
|
+
{ name: "zombies [sitemap|file]", shortcut: "z", desc: "Zombie Content Purger & Crawl Equity Optimizer: CEDI score (0-100), automated 410/301 triage & 1-click server rules", flags: ["--days", "--threshold", "--action", "--format", "--inventory", "--csv", "--json"] },
|
|
106
|
+
{ name: "soft-404 [url|file]", shortcut: "404", desc: "404 & Soft-404 Crawl Error Diagnostic: Detects thin/empty pages returning 200 OK, calculates crawl waste & synthesizes smart 301/410 redirect rules", flags: ["--concurrency", "--threshold", "--format", "--csv", "--json"] },
|
|
107
|
+
{ name: "switch <domain>", shortcut: "use", desc: "Instant agency domain & credentials switcher (domain, alias, or index #)", flags: ["--json"] },
|
|
108
|
+
{ name: "vault [action]", shortcut: nil, desc: "Agency Credential Vault: AES-256-GCM encrypted store for 50+ service accounts (list, add, remove, status)", flags: ["--domain", "--alias", "--property", "--json"] },
|
|
71
109
|
{ name: "domains", shortcut: nil, desc: "List verified domains and GA4 property links", flags: [] },
|
|
110
|
+
{ name: "doctor", shortcut: "doc", desc: "Zero-Dependency Architecture Certification & System Health: Verifies 100% pure Ruby stdlib, cold-start latency (<100ms), OpenSSL cipher support & config permissions", flags: ["--strict", "--fix", "--json"] },
|
|
72
111
|
{ name: "where", shortcut: nil, desc: "Inspect installation path, active key, and config file", flags: [] },
|
|
73
112
|
{ name: "connect", shortcut: nil, desc: "1-Click Setup Wizard: auto-detects key or drag & drop", flags: [] },
|
|
74
113
|
{ name: "connect-ga4", shortcut: nil, desc: "Interactive GA4 linking wizard", flags: [] },
|
|
75
|
-
{ name: "open", shortcut: nil, desc: "Reveal configuration directory (~/.config/gsc) in Finder", flags: [] },
|
|
76
114
|
{ name: "prompts [id]", shortcut: "pb", desc: "25 Autonomous AI SEO Playbooks & ready-to-paste prompts", flags: ["--copy", "--json"] },
|
|
77
115
|
{ name: "skills [install|show]", shortcut: nil, desc: "Inspect or auto-install AI Agent Skill", flags: [] },
|
|
78
116
|
{ name: "commands", shortcut: nil, desc: "List all commands (human-readable or JSON with --json)", flags: ["--json"] }
|
|
@@ -267,7 +305,7 @@ COMMAND_REGISTRY = [
|
|
|
267
305
|
2. **Optimal Length**: Keep `<title>` between **50 and 60 characters** (maximum 580px width) so Google does not truncate with `...`.
|
|
268
306
|
3. **Emotional Hook / CTR Multiplier**: Include brackets `[Free Calculator]`, actionable numbers (`10 Best`, `2026 Checklist`), or primary value props.
|
|
269
307
|
4. **Brand Suffix**: Always append ` | BrandName` at the end.
|
|
270
|
-
5. **Meta Description**: 130–155 characters summarizing the page benefit with a clear call-to-action (e.g. *"
|
|
308
|
+
5. **Meta Description**: 130–155 characters summarizing the page benefit with a clear call-to-action (e.g. *"Inspect technical SEO health, Core Web Vitals, and index coverage instantly. Free diagnostic audit."*).
|
|
271
309
|
6. **Heading Polish**: Ensure `<h1>` matches search intent and **never ends with a period (`.`)**.
|
|
272
310
|
|
|
273
311
|
#### Step 4: Immediately Trigger Googlebot Indexing
|
data/lib/gsc/config.rb
CHANGED
|
@@ -153,7 +153,7 @@ def self.load_saved_keywords(domain, identifier)
|
|
|
153
153
|
|
|
154
154
|
target_file = if identifier.to_s =~ /^\d+$/
|
|
155
155
|
idx = identifier.to_i - 1
|
|
156
|
-
list[idx]&.fetch('path', nil)
|
|
156
|
+
idx >= 0 ? list[idx]&.fetch('path', nil) : nil
|
|
157
157
|
else
|
|
158
158
|
found = list.find { |item| item['file'] == identifier || item['seed'] == identifier || item['file'].include?(identifier.to_s) }
|
|
159
159
|
found ? found['path'] : nil
|
|
@@ -171,7 +171,7 @@ def self.delete_saved_keywords(domain, identifier)
|
|
|
171
171
|
|
|
172
172
|
target_file = if identifier.to_s =~ /^\d+$/
|
|
173
173
|
idx = identifier.to_i - 1
|
|
174
|
-
list[idx]&.fetch('path', nil)
|
|
174
|
+
idx >= 0 ? list[idx]&.fetch('path', nil) : nil
|
|
175
175
|
else
|
|
176
176
|
found = list.find { |item| item['file'] == identifier || item['file'].include?(identifier.to_s) }
|
|
177
177
|
found ? found['path'] : nil
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module GSC
|
|
4
|
+
class CtrCurve
|
|
5
|
+
# Empirical Google Organic SERP CTR Curve benchmark (Ahrefs / Backlinko / Advanced Web Ranking composite)
|
|
6
|
+
BENCHMARK_CTR = {
|
|
7
|
+
1 => 28.0,
|
|
8
|
+
2 => 15.5,
|
|
9
|
+
3 => 11.0,
|
|
10
|
+
4 => 8.0,
|
|
11
|
+
5 => 6.0,
|
|
12
|
+
6 => 4.5,
|
|
13
|
+
7 => 3.5,
|
|
14
|
+
8 => 2.8,
|
|
15
|
+
9 => 2.2,
|
|
16
|
+
10 => 1.8,
|
|
17
|
+
11 => 1.4,
|
|
18
|
+
12 => 1.2,
|
|
19
|
+
13 => 1.0,
|
|
20
|
+
14 => 0.9,
|
|
21
|
+
15 => 0.8,
|
|
22
|
+
16 => 0.7,
|
|
23
|
+
17 => 0.6,
|
|
24
|
+
18 => 0.5,
|
|
25
|
+
19 => 0.4,
|
|
26
|
+
20 => 0.4
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
DEFAULT_PAGE2_CTR = 0.5
|
|
30
|
+
DEFAULT_PAGE3_CTR = 0.2
|
|
31
|
+
|
|
32
|
+
def self.benchmark_for(position)
|
|
33
|
+
pos = position.to_f.round
|
|
34
|
+
return BENCHMARK_CTR[1] if pos <= 1
|
|
35
|
+
return BENCHMARK_CTR[pos] if BENCHMARK_CTR.key?(pos)
|
|
36
|
+
return DEFAULT_PAGE2_CTR if pos <= 20
|
|
37
|
+
|
|
38
|
+
DEFAULT_PAGE3_CTR
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.project(rows, target_pos: 3, min_imp: 10)
|
|
42
|
+
target_pos = target_pos.to_i
|
|
43
|
+
target_pos = 3 if target_pos <= 0
|
|
44
|
+
target_ctr = benchmark_for(target_pos)
|
|
45
|
+
|
|
46
|
+
analyzed = []
|
|
47
|
+
total_incremental = 0
|
|
48
|
+
underperforming_queries = []
|
|
49
|
+
|
|
50
|
+
rows.each do |row|
|
|
51
|
+
imp = row[:impressions] || row['impressions'] || 0
|
|
52
|
+
next if imp < min_imp
|
|
53
|
+
|
|
54
|
+
clicks = row[:clicks] || row['clicks'] || 0
|
|
55
|
+
actual_ctr = if imp > 0
|
|
56
|
+
(clicks.to_f / imp * 100.0).round(2)
|
|
57
|
+
elsif (row[:ctr] || row['ctr'])
|
|
58
|
+
(row[:ctr] || row['ctr']).to_f.round(2)
|
|
59
|
+
else
|
|
60
|
+
0.0
|
|
61
|
+
end
|
|
62
|
+
pos = (row[:position] || row['position'] || 100.0).to_f.round(1)
|
|
63
|
+
expected_ctr = benchmark_for(pos)
|
|
64
|
+
|
|
65
|
+
# Gain projection if ranking moved to target_pos
|
|
66
|
+
projected_clicks = ((target_ctr / 100.0) * imp).round
|
|
67
|
+
incremental_clicks = [projected_clicks - clicks, 0].max
|
|
68
|
+
|
|
69
|
+
# Priority score: Higher incremental clicks and closer to top page 1 = higher leverage
|
|
70
|
+
proximity_factor = [1.0, (25.0 - [pos, 25.0].min) / 20.0].max
|
|
71
|
+
opportunity_score = (incremental_clicks * proximity_factor).round(1)
|
|
72
|
+
|
|
73
|
+
# Flag title tag optimization wins: ranking on Page 1 (<=10) but CTR is < 50% of benchmark
|
|
74
|
+
is_underperforming = pos <= 10.0 && actual_ctr < (expected_ctr * 0.6) && imp >= 20
|
|
75
|
+
|
|
76
|
+
data = {
|
|
77
|
+
query: row[:query] || row['query'],
|
|
78
|
+
current_position: pos,
|
|
79
|
+
target_position: target_pos,
|
|
80
|
+
impressions: imp,
|
|
81
|
+
current_clicks: clicks,
|
|
82
|
+
current_ctr: actual_ctr,
|
|
83
|
+
expected_ctr: expected_ctr,
|
|
84
|
+
target_ctr: target_ctr,
|
|
85
|
+
projected_clicks: projected_clicks,
|
|
86
|
+
simulated_clicks: projected_clicks, # alias for backward compatibility
|
|
87
|
+
incremental_clicks: incremental_clicks,
|
|
88
|
+
opportunity_score: opportunity_score,
|
|
89
|
+
underperforming: is_underperforming
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
analyzed << data
|
|
93
|
+
total_incremental += incremental_clicks
|
|
94
|
+
underperforming_queries << data if is_underperforming
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
# Sort by highest opportunity score
|
|
98
|
+
analyzed.sort_by! { |r| -r[:opportunity_score] }
|
|
99
|
+
underperforming_queries.sort_by! { |r| -r[:impressions] }
|
|
100
|
+
|
|
101
|
+
{
|
|
102
|
+
target_position: target_pos,
|
|
103
|
+
target_ctr: target_ctr,
|
|
104
|
+
total_queries_analyzed: analyzed.size,
|
|
105
|
+
total_incremental_clicks: total_incremental,
|
|
106
|
+
opportunities: analyzed,
|
|
107
|
+
underperformers: underperforming_queries
|
|
108
|
+
}
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
class << self
|
|
112
|
+
alias simulate project
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require 'date'
|
|
4
|
+
|
|
5
|
+
module GSC
|
|
6
|
+
class DecayPredictor
|
|
7
|
+
# Minimum impressions over the 28-day window to evaluate decay
|
|
8
|
+
DEFAULT_MIN_IMP = 10
|
|
9
|
+
|
|
10
|
+
def self.analyze_timeseries(rows, dimension: 'page', min_imp: DEFAULT_MIN_IMP)
|
|
11
|
+
# rows is array of { "keys" => [entity, "YYYY-MM-DD"], "clicks" => n, "impressions" => n, "position" => f }
|
|
12
|
+
# Group by entity
|
|
13
|
+
entity_days = Hash.new { |h, k| h[k] = {} }
|
|
14
|
+
all_dates = []
|
|
15
|
+
|
|
16
|
+
rows.each do |r|
|
|
17
|
+
entity = r['keys'][0]
|
|
18
|
+
date_str = r['keys'][1]
|
|
19
|
+
all_dates << date_str
|
|
20
|
+
entity_days[entity][date_str] = {
|
|
21
|
+
clicks: (r['clicks'] || 0).to_i,
|
|
22
|
+
impressions: (r['impressions'] || 0).to_i,
|
|
23
|
+
position: (r['position'] || 0.0).to_f
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
all_dates = all_dates.uniq.sort
|
|
28
|
+
return empty_result(dimension) if all_dates.empty?
|
|
29
|
+
|
|
30
|
+
# Partition the dates into 4 chronological buckets (oldest to newest)
|
|
31
|
+
# total dates usually 28 (or whatever was queried)
|
|
32
|
+
chunk_size = (all_dates.size / 4.0).ceil
|
|
33
|
+
chunk_size = 1 if chunk_size < 1
|
|
34
|
+
|
|
35
|
+
w4_dates = all_dates[0...chunk_size] || []
|
|
36
|
+
w3_dates = all_dates[chunk_size...(chunk_size * 2)] || []
|
|
37
|
+
w2_dates = all_dates[(chunk_size * 2)...(chunk_size * 3)] || []
|
|
38
|
+
w1_dates = all_dates[(chunk_size * 3)..] || []
|
|
39
|
+
|
|
40
|
+
results = []
|
|
41
|
+
|
|
42
|
+
entity_days.each do |entity, days|
|
|
43
|
+
w4 = sum_window(days, w4_dates)
|
|
44
|
+
w3 = sum_window(days, w3_dates)
|
|
45
|
+
w2 = sum_window(days, w2_dates)
|
|
46
|
+
w1 = sum_window(days, w1_dates)
|
|
47
|
+
|
|
48
|
+
total_imp = w1[:impressions] + w2[:impressions] + w3[:impressions] + w4[:impressions]
|
|
49
|
+
total_clicks = w1[:clicks] + w2[:clicks] + w3[:clicks] + w4[:clicks]
|
|
50
|
+
|
|
51
|
+
next if total_imp < min_imp
|
|
52
|
+
|
|
53
|
+
# 7-day velocity: Compare W1 (latest 7d) vs W2 (prior 7d)
|
|
54
|
+
w1_clicks = w1[:clicks]
|
|
55
|
+
w2_clicks = w2[:clicks]
|
|
56
|
+
w1_imp = w1[:impressions]
|
|
57
|
+
w2_imp = w2[:impressions]
|
|
58
|
+
|
|
59
|
+
vel_7d_clicks_pct = calc_pct_change(w1_clicks, w2_clicks)
|
|
60
|
+
vel_7d_imp_pct = calc_pct_change(w1_imp, w2_imp)
|
|
61
|
+
|
|
62
|
+
# 14-day velocity: (W1 + W2) vs (W3 + W4)
|
|
63
|
+
h1_clicks = w1_clicks + w2_clicks
|
|
64
|
+
h2_clicks = w3[:clicks] + w4[:clicks]
|
|
65
|
+
h1_imp = w1_imp + w2_imp
|
|
66
|
+
h2_imp = w3[:impressions] + w4[:impressions]
|
|
67
|
+
|
|
68
|
+
vel_14d_clicks_pct = calc_pct_change(h1_clicks, h2_clicks)
|
|
69
|
+
vel_14d_imp_pct = calc_pct_change(h1_imp, h2_imp)
|
|
70
|
+
|
|
71
|
+
# 28-day Macro Velocity: W1 vs W4
|
|
72
|
+
vel_28d_clicks_pct = calc_pct_change(w1_clicks, w4[:clicks])
|
|
73
|
+
vel_28d_imp_pct = calc_pct_change(w1_imp, w4[:impressions])
|
|
74
|
+
|
|
75
|
+
# Historical linear regression slope across the 4 weeks:
|
|
76
|
+
# x = [1, 2, 3, 4], y = [w4_imp, w3_imp, w2_imp, w1_imp]
|
|
77
|
+
imp_series = [w4[:impressions], w3[:impressions], w2[:impressions], w1[:impressions]]
|
|
78
|
+
click_series = [w4[:clicks], w3[:clicks], w2[:clicks], w1[:clicks]]
|
|
79
|
+
pos_series = [w4[:position], w3[:position], w2[:position], w1[:position]].compact
|
|
80
|
+
|
|
81
|
+
imp_slope = calc_slope(imp_series)
|
|
82
|
+
click_slope = calc_slope(click_series)
|
|
83
|
+
pos_drift = (w1[:position] && w4[:position]) ? (w1[:position] - w4[:position]).round(1) : 0.0
|
|
84
|
+
|
|
85
|
+
# Classification
|
|
86
|
+
classification, severity = classify_decay(
|
|
87
|
+
vel_7d_imp_pct: vel_7d_imp_pct,
|
|
88
|
+
vel_14d_imp_pct: vel_14d_imp_pct,
|
|
89
|
+
vel_28d_imp_pct: vel_28d_imp_pct,
|
|
90
|
+
imp_slope: imp_slope,
|
|
91
|
+
pos_drift: pos_drift,
|
|
92
|
+
total_imp: total_imp,
|
|
93
|
+
w1: w1,
|
|
94
|
+
w4: w4
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
# Projected loss if trend continues for 30 days
|
|
98
|
+
projected_monthly_clicks_lost = 0
|
|
99
|
+
if click_slope.negative?
|
|
100
|
+
projected_monthly_clicks_lost = [(-click_slope * 4).round, w1_clicks * 4].min
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
prescription = formulate_prescription(
|
|
104
|
+
classification: classification,
|
|
105
|
+
pos_drift: pos_drift,
|
|
106
|
+
vel_7d_imp_pct: vel_7d_imp_pct,
|
|
107
|
+
vel_14d_imp_pct: vel_14d_imp_pct,
|
|
108
|
+
w1_imp: w1_imp,
|
|
109
|
+
w1_clicks: w1_clicks,
|
|
110
|
+
entity: entity,
|
|
111
|
+
dimension: dimension
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
results << {
|
|
115
|
+
entity: entity,
|
|
116
|
+
dimension: dimension,
|
|
117
|
+
classification: classification,
|
|
118
|
+
severity: severity,
|
|
119
|
+
total_impressions: total_imp,
|
|
120
|
+
total_clicks: total_clicks,
|
|
121
|
+
current_7d_impressions: w1_imp,
|
|
122
|
+
prior_7d_impressions: w2_imp,
|
|
123
|
+
current_7d_clicks: w1_clicks,
|
|
124
|
+
prior_7d_clicks: w2_clicks,
|
|
125
|
+
vel_7d_imp_pct: vel_7d_imp_pct,
|
|
126
|
+
vel_7d_clicks_pct: vel_7d_clicks_pct,
|
|
127
|
+
vel_14d_imp_pct: vel_14d_imp_pct,
|
|
128
|
+
vel_14d_clicks_pct: vel_14d_clicks_pct,
|
|
129
|
+
vel_28d_imp_pct: vel_28d_imp_pct,
|
|
130
|
+
vel_28d_clicks_pct: vel_28d_clicks_pct,
|
|
131
|
+
weekly_impressions: imp_series,
|
|
132
|
+
weekly_clicks: click_series,
|
|
133
|
+
impression_slope: imp_slope.round(2),
|
|
134
|
+
click_slope: click_slope.round(2),
|
|
135
|
+
current_position: w1[:position] ? w1[:position].round(1) : nil,
|
|
136
|
+
prior_position: w4[:position] ? w4[:position].round(1) : nil,
|
|
137
|
+
position_drift: pos_drift,
|
|
138
|
+
projected_monthly_clicks_lost: projected_monthly_clicks_lost,
|
|
139
|
+
prescription: prescription
|
|
140
|
+
}
|
|
141
|
+
end
|
|
142
|
+
|
|
143
|
+
# Sort: Critical decay first, then by negative slope
|
|
144
|
+
decaying_items = results.select { |r| %w[CRITICAL_COLLAPSE ACCELERATING_DECAY STEADY_EROSION].include?(r[:classification]) }
|
|
145
|
+
.sort_by { |r| [severity_weight(r[:severity]), r[:impression_slope]] }
|
|
146
|
+
|
|
147
|
+
surging_items = results.select { |r| r[:classification] == 'SURGING' }
|
|
148
|
+
.sort_by { |r| -r[:vel_14d_imp_pct] }
|
|
149
|
+
|
|
150
|
+
stable_items = results.select { |r| r[:classification] == 'STABLE' }
|
|
151
|
+
|
|
152
|
+
health_score = calculate_health_score(results)
|
|
153
|
+
|
|
154
|
+
{
|
|
155
|
+
dimension: dimension,
|
|
156
|
+
total_evaluated: results.size,
|
|
157
|
+
decaying_count: decaying_items.size,
|
|
158
|
+
surging_count: surging_items.size,
|
|
159
|
+
stable_count: stable_items.size,
|
|
160
|
+
health_score: health_score,
|
|
161
|
+
health_grade: grade_for_score(health_score),
|
|
162
|
+
decaying: decaying_items,
|
|
163
|
+
surging: surging_items,
|
|
164
|
+
date_range: {
|
|
165
|
+
oldest: all_dates.first,
|
|
166
|
+
newest: all_dates.last,
|
|
167
|
+
total_days: all_dates.size
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
def self.sum_window(days_map, dates)
|
|
173
|
+
clicks = 0
|
|
174
|
+
impressions = 0
|
|
175
|
+
positions = []
|
|
176
|
+
weighted_pos_sum = 0.0
|
|
177
|
+
weighted_pos_imp = 0
|
|
178
|
+
|
|
179
|
+
dates.each do |d|
|
|
180
|
+
entry = days_map[d]
|
|
181
|
+
next unless entry
|
|
182
|
+
|
|
183
|
+
clicks += entry[:clicks]
|
|
184
|
+
impressions += entry[:impressions]
|
|
185
|
+
if entry[:position] && entry[:position] > 0
|
|
186
|
+
positions << entry[:position]
|
|
187
|
+
if entry[:impressions] > 0
|
|
188
|
+
weighted_pos_sum += (entry[:position] * entry[:impressions])
|
|
189
|
+
weighted_pos_imp += entry[:impressions]
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
|
|
194
|
+
avg_pos = if weighted_pos_imp > 0
|
|
195
|
+
weighted_pos_sum / weighted_pos_imp.to_f
|
|
196
|
+
elsif positions.any?
|
|
197
|
+
positions.sum / positions.size.to_f
|
|
198
|
+
else
|
|
199
|
+
nil
|
|
200
|
+
end
|
|
201
|
+
|
|
202
|
+
{ clicks: clicks, impressions: impressions, position: avg_pos }
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
def self.calc_pct_change(curr, prior)
|
|
206
|
+
return 0.0 if prior.to_f.zero? && curr.to_f.zero?
|
|
207
|
+
return 100.0 if prior.to_f.zero? && curr.positive?
|
|
208
|
+
return -100.0 if curr.to_f.zero? && prior.positive?
|
|
209
|
+
|
|
210
|
+
(((curr - prior) / prior.to_f) * 100.0).round(1)
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
def self.calc_slope(series)
|
|
214
|
+
# x = [1, 2, 3, 4], y = series
|
|
215
|
+
n = series.size
|
|
216
|
+
return 0.0 if n < 2
|
|
217
|
+
|
|
218
|
+
x_coords = (1..n).to_a
|
|
219
|
+
x_mean = x_coords.sum / n.to_f
|
|
220
|
+
y_mean = series.sum / n.to_f
|
|
221
|
+
|
|
222
|
+
numerator = 0.0
|
|
223
|
+
denominator = 0.0
|
|
224
|
+
|
|
225
|
+
n.times do |i|
|
|
226
|
+
x_diff = x_coords[i] - x_mean
|
|
227
|
+
y_diff = series[i] - y_mean
|
|
228
|
+
numerator += (x_diff * y_diff)
|
|
229
|
+
denominator += (x_diff**2)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
return 0.0 if denominator.zero?
|
|
233
|
+
|
|
234
|
+
numerator / denominator
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def self.classify_decay(vel_7d_imp_pct:, vel_14d_imp_pct:, vel_28d_imp_pct:, imp_slope:, pos_drift:, total_imp:, w1:, w4:)
|
|
238
|
+
# pos_drift > 0 means current position number is higher (e.g. dropped from 5 to 9 => +4 drift)
|
|
239
|
+
if (vel_14d_imp_pct <= -50.0 && total_imp >= 20) || (w1[:impressions].zero? && w4[:impressions] >= 10)
|
|
240
|
+
['CRITICAL_COLLAPSE', 'CRITICAL']
|
|
241
|
+
elsif vel_7d_imp_pct <= -35.0 && vel_7d_imp_pct < (vel_14d_imp_pct - 15.0)
|
|
242
|
+
['ACCELERATING_DECAY', 'HIGH']
|
|
243
|
+
elsif imp_slope.negative? && (vel_14d_imp_pct <= -20.0 || pos_drift >= 3.0)
|
|
244
|
+
['STEADY_EROSION', 'MODERATE']
|
|
245
|
+
elsif vel_14d_imp_pct >= 25.0 || imp_slope >= 5.0
|
|
246
|
+
['SURGING', 'INFO']
|
|
247
|
+
else
|
|
248
|
+
['STABLE', 'LOW']
|
|
249
|
+
end
|
|
250
|
+
end
|
|
251
|
+
|
|
252
|
+
def self.formulate_prescription(classification:, pos_drift:, vel_7d_imp_pct:, vel_14d_imp_pct:, w1_imp:, w1_clicks:, entity:, dimension:)
|
|
253
|
+
case classification
|
|
254
|
+
when 'CRITICAL_COLLAPSE'
|
|
255
|
+
if pos_drift >= 5.0
|
|
256
|
+
"🚨 Severe SERP Ranking Drop (+#{pos_drift} pos): Inspect for indexability errors, manual actions, or lost canonical tag."
|
|
257
|
+
elsif w1_imp.zero?
|
|
258
|
+
"💀 Zero Search Impressions in Past 7 Days: Run 'gsc inspect #{entity}' to verify Googlebot coverage."
|
|
259
|
+
else
|
|
260
|
+
"⚠️ High-Volume Collapse (#{vel_14d_imp_pct}% over 14d): Prioritize emergency content refresh and re-index via 'gsc index #{entity}'."
|
|
261
|
+
end
|
|
262
|
+
when 'ACCELERATING_DECAY'
|
|
263
|
+
"⚡ Rapid 7-Day Traffic Drop (#{vel_7d_imp_pct}%): Competitor surge detected. Enhance headline hook and verify schema."
|
|
264
|
+
when 'STEADY_EROSION'
|
|
265
|
+
if pos_drift >= 2.0
|
|
266
|
+
"📉 Gradual Position Drift (+#{pos_drift}): Update dated statistics, expand FAQ answers, and add 2-3 internal links."
|
|
267
|
+
else
|
|
268
|
+
"📉 Search Demand Erosion (#{vel_14d_imp_pct}%): Content freshness fatigue. Add 2026 insights and interactive widgets."
|
|
269
|
+
end
|
|
270
|
+
when 'SURGING'
|
|
271
|
+
"🚀 Surging Velocity (+#{vel_14d_imp_pct}%): Momentum window! Add related internal links to capture additional cluster queries."
|
|
272
|
+
else
|
|
273
|
+
"✅ Consistent Performance: Maintain current on-page content and monitor monthly."
|
|
274
|
+
end
|
|
275
|
+
end
|
|
276
|
+
|
|
277
|
+
def self.severity_weight(sev)
|
|
278
|
+
case sev
|
|
279
|
+
when 'CRITICAL' then 1
|
|
280
|
+
when 'HIGH' then 2
|
|
281
|
+
when 'MODERATE' then 3
|
|
282
|
+
else 4
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
def self.calculate_health_score(results)
|
|
287
|
+
return 100 if results.empty?
|
|
288
|
+
|
|
289
|
+
critical = results.count { |r| r[:severity] == 'CRITICAL' }
|
|
290
|
+
high = results.count { |r| r[:severity] == 'HIGH' }
|
|
291
|
+
moderate = results.count { |r| r[:severity] == 'MODERATE' }
|
|
292
|
+
|
|
293
|
+
score = 100 - (critical * 25) - (high * 15) - (moderate * 5)
|
|
294
|
+
[[score, 0].max, 100].min
|
|
295
|
+
end
|
|
296
|
+
|
|
297
|
+
def self.grade_for_score(score)
|
|
298
|
+
case score
|
|
299
|
+
when 90..100 then 'A'
|
|
300
|
+
when 80..89 then 'B'
|
|
301
|
+
when 70..79 then 'C'
|
|
302
|
+
when 60..69 then 'D'
|
|
303
|
+
else 'F'
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
|
|
307
|
+
def self.empty_result(dim)
|
|
308
|
+
{
|
|
309
|
+
dimension: dim,
|
|
310
|
+
total_evaluated: 0,
|
|
311
|
+
decaying_count: 0,
|
|
312
|
+
surging_count: 0,
|
|
313
|
+
stable_count: 0,
|
|
314
|
+
health_score: 100,
|
|
315
|
+
health_grade: 'A',
|
|
316
|
+
decaying: [],
|
|
317
|
+
surging: [],
|
|
318
|
+
date_range: {}
|
|
319
|
+
}
|
|
320
|
+
end
|
|
321
|
+
end
|
|
322
|
+
end
|