nerima 0.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 +7 -0
- data/exe/nerima +6 -0
- data/lib/seo/analyser.rb +160 -0
- data/lib/seo/cli/config.rb +46 -0
- data/lib/seo/cli/version.rb +7 -0
- data/lib/seo/cli.rb +162 -0
- data/lib/seo/output/reporter.rb +231 -0
- data/lib/seo/output/terminal.rb +110 -0
- data/lib/seo/providers/anthropic.rb +26 -0
- data/lib/seo/providers/base.rb +11 -0
- data/lib/seo/providers/gemini.rb +36 -0
- data/lib/seo/providers/ollama.rb +37 -0
- data/lib/seo/providers/openai.rb +38 -0
- data/lib/seo/providers.rb +32 -0
- data/lib/seo/result.rb +27 -0
- data/lib/seo/scanner.rb +24 -0
- metadata +195 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 1463ee71dbe01193461cb0c748a2d58d26dfd435a950edf1d943bd9591d2c8b3
|
|
4
|
+
data.tar.gz: 01321c4e553823f4116aa49b840dc1769cdfc7a2007fba87b19b9a4602c153c7
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: 26531a5db88201918c1c6e9b0839c9ea46416c5e4ca689cd6a59b2bfe5723389dfeac8f3a22d7b4789a0b853df398220725cf2543344c693874fdca3d2066f4f
|
|
7
|
+
data.tar.gz: 53b125cf78c956aada5ea5f0676915a2342ef0debd1c80a76a362e26a55afef5814124a688afbb08f187ebc076d581354c1948810218e13687bb649f7fe48023
|
data/exe/nerima
ADDED
data/lib/seo/analyser.rb
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "result"
|
|
4
|
+
|
|
5
|
+
module Seo
|
|
6
|
+
class Analyser
|
|
7
|
+
def initialize(provider:)
|
|
8
|
+
@provider = provider
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
MAX_CONCURRENT = 5
|
|
12
|
+
|
|
13
|
+
def analyse(files, &on_result)
|
|
14
|
+
results = Array.new(files.length)
|
|
15
|
+
queue = Queue.new
|
|
16
|
+
files.each_with_index { |f, i| queue << [f, i] }
|
|
17
|
+
mutex = Mutex.new
|
|
18
|
+
|
|
19
|
+
[files.length, MAX_CONCURRENT].min.times.map do
|
|
20
|
+
Thread.new do
|
|
21
|
+
while (job = begin; queue.pop(true); rescue ThreadError; nil; end)
|
|
22
|
+
file, i = job
|
|
23
|
+
begin
|
|
24
|
+
content = File.read(file)
|
|
25
|
+
result = analyse_file(file, content)
|
|
26
|
+
rescue => e
|
|
27
|
+
result = Result.new(
|
|
28
|
+
file: file,
|
|
29
|
+
issues: [{ severity: :critical, check: "internal", message: "Could not read file: #{e.message}", line: nil }],
|
|
30
|
+
suggestions: [],
|
|
31
|
+
fixed_content: nil
|
|
32
|
+
)
|
|
33
|
+
end
|
|
34
|
+
mutex.synchronize do
|
|
35
|
+
results[i] = result
|
|
36
|
+
on_result&.call(result)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end.each(&:join)
|
|
41
|
+
|
|
42
|
+
results
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# Called only by `seo fix` — separate API call, higher token budget
|
|
46
|
+
def fix(file)
|
|
47
|
+
content = File.read(file)
|
|
48
|
+
fixed = call_api(build_fix_prompt(file, content), max_tokens: 4096)
|
|
49
|
+
result = analyse_file(file, content)
|
|
50
|
+
Result.new(
|
|
51
|
+
file: result.file,
|
|
52
|
+
issues: result.issues,
|
|
53
|
+
suggestions: result.suggestions,
|
|
54
|
+
fixed_content: fixed.strip != content.strip ? fixed : nil
|
|
55
|
+
)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def analyse_file(file, content)
|
|
61
|
+
raw = call_api(build_prompt(file, content), max_tokens: 2048)
|
|
62
|
+
parse_response(file, raw)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def call_api(prompt, max_tokens:)
|
|
66
|
+
@provider.complete(prompt, max_tokens: max_tokens)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def build_prompt(file, content)
|
|
70
|
+
<<~PROMPT
|
|
71
|
+
You are an SEO expert analysing a web template file. Analyse the following file for SEO issues.
|
|
72
|
+
|
|
73
|
+
File: #{file}
|
|
74
|
+
Template type: #{detect_template_type(file)}
|
|
75
|
+
|
|
76
|
+
```
|
|
77
|
+
#{content.slice(0, 8000)}
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Return ONLY a raw JSON object — no markdown, no code fences, no explanation before or after. Use this exact structure:
|
|
81
|
+
{"issues":[{"severity":"critical","check":"meta","message":"description","line":null}],"suggestions":["suggestion"],"ai_risk":"none","ai_risk_reason":"reason"}
|
|
82
|
+
|
|
83
|
+
severity: "critical" or "warning"
|
|
84
|
+
check: one of meta, headings, images, links, canonical, opengraph, schema, content
|
|
85
|
+
ai_risk: "none", "low", "medium", or "high"
|
|
86
|
+
|
|
87
|
+
Check for:
|
|
88
|
+
- Meta title: present, 50-60 chars, contains primary keyword
|
|
89
|
+
- Meta description: present, 150-160 chars, compelling
|
|
90
|
+
- Heading hierarchy: single H1, logical H2/H3 structure, no skipped levels
|
|
91
|
+
- Images: all have descriptive alt text
|
|
92
|
+
- Canonical tag present
|
|
93
|
+
- Open Graph tags: og:title, og:description, og:image
|
|
94
|
+
- Schema/structured data present
|
|
95
|
+
- Content: thin content, keyword usage, internal linking
|
|
96
|
+
- AI risk: is this content likely to be replaced by Google AI overviews?
|
|
97
|
+
PROMPT
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def build_fix_prompt(file, content)
|
|
101
|
+
<<~PROMPT
|
|
102
|
+
You are an SEO expert. Return an improved version of the following web template file.
|
|
103
|
+
|
|
104
|
+
File: #{file}
|
|
105
|
+
Template type: #{detect_template_type(file)}
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
#{content}
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
Return ONLY the corrected file content — no explanation, no markdown fences. Improve: meta tags, alt text, heading structure, canonical tags, Open Graph tags, structured data. Do not rewrite body copy or change layout.
|
|
112
|
+
PROMPT
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def detect_template_type(file)
|
|
116
|
+
case File.extname(file)
|
|
117
|
+
when ".erb" then "ERB (Ruby)"
|
|
118
|
+
when ".haml" then "Haml"
|
|
119
|
+
when ".slim" then "Slim"
|
|
120
|
+
when ".jinja", ".jinja2" then "Jinja2 (Python)"
|
|
121
|
+
when ".twig" then "Twig (PHP)"
|
|
122
|
+
when ".njk" then "Nunjucks (JS)"
|
|
123
|
+
when ".html" then "HTML"
|
|
124
|
+
else "Unknown"
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def parse_response(file, raw)
|
|
129
|
+
start_idx = raw.index("{")
|
|
130
|
+
end_idx = raw.rindex("}")
|
|
131
|
+
json_str = (start_idx && end_idx) ? raw[start_idx..end_idx] : raw
|
|
132
|
+
data = JSON.parse(json_str)
|
|
133
|
+
|
|
134
|
+
issues = (data["issues"] || []).map do |i|
|
|
135
|
+
{
|
|
136
|
+
severity: i["severity"].to_sym,
|
|
137
|
+
check: i["check"],
|
|
138
|
+
message: i["message"],
|
|
139
|
+
line: i["line"],
|
|
140
|
+
ai_risk: data["ai_risk"],
|
|
141
|
+
ai_risk_reason: data["ai_risk_reason"]
|
|
142
|
+
}
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
Result.new(
|
|
146
|
+
file: file,
|
|
147
|
+
issues: issues,
|
|
148
|
+
suggestions: data["suggestions"] || [],
|
|
149
|
+
fixed_content: nil
|
|
150
|
+
)
|
|
151
|
+
rescue JSON::ParserError
|
|
152
|
+
Result.new(
|
|
153
|
+
file: file,
|
|
154
|
+
issues: [{ severity: :warning, check: "internal", message: "Could not parse AI response", line: nil }],
|
|
155
|
+
suggestions: [],
|
|
156
|
+
fixed_content: nil
|
|
157
|
+
)
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "yaml"
|
|
4
|
+
|
|
5
|
+
module Seo
|
|
6
|
+
module CLI
|
|
7
|
+
module Config
|
|
8
|
+
CONFIG_FILE = ".seo.yml"
|
|
9
|
+
|
|
10
|
+
DEFAULTS = {
|
|
11
|
+
"include" => [
|
|
12
|
+
"**/*.html.erb",
|
|
13
|
+
"**/*.html.haml",
|
|
14
|
+
"**/*.html.slim",
|
|
15
|
+
"**/*.html",
|
|
16
|
+
"**/*.jinja",
|
|
17
|
+
"**/*.jinja2",
|
|
18
|
+
"**/*.twig",
|
|
19
|
+
"**/*.blade.php",
|
|
20
|
+
"**/*.njk"
|
|
21
|
+
],
|
|
22
|
+
"exclude" => [
|
|
23
|
+
"vendor/**",
|
|
24
|
+
"node_modules/**",
|
|
25
|
+
"tmp/**",
|
|
26
|
+
".git/**"
|
|
27
|
+
],
|
|
28
|
+
"checks" => %w[meta headings images links canonical opengraph schema],
|
|
29
|
+
"provider" => "anthropic",
|
|
30
|
+
"api_key" => nil
|
|
31
|
+
}.freeze
|
|
32
|
+
|
|
33
|
+
def self.load
|
|
34
|
+
if File.exist?(CONFIG_FILE)
|
|
35
|
+
DEFAULTS.merge(YAML.safe_load_file(CONFIG_FILE) || {})
|
|
36
|
+
else
|
|
37
|
+
DEFAULTS
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def self.write_default
|
|
42
|
+
File.write(CONFIG_FILE, YAML.dump(DEFAULTS))
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/seo/cli.rb
ADDED
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "thor"
|
|
4
|
+
require "json"
|
|
5
|
+
require "pastel"
|
|
6
|
+
require "tty-prompt"
|
|
7
|
+
|
|
8
|
+
require_relative "cli/version"
|
|
9
|
+
require_relative "cli/config"
|
|
10
|
+
require_relative "scanner"
|
|
11
|
+
require_relative "analyser"
|
|
12
|
+
require_relative "providers"
|
|
13
|
+
require_relative "output/terminal"
|
|
14
|
+
require_relative "output/reporter"
|
|
15
|
+
|
|
16
|
+
module Seo
|
|
17
|
+
module CLI
|
|
18
|
+
LOGO = <<~LOGO
|
|
19
|
+
_ _ ___ ___ ___ __ __ _
|
|
20
|
+
| \\| | __| _ \\_ _| \\/ | /_\\
|
|
21
|
+
| .` | _|| /| || |\\/| |/ _ \\
|
|
22
|
+
|_|\\_|___|_|_\\___|_| |_/_/ \\_\\
|
|
23
|
+
LOGO
|
|
24
|
+
|
|
25
|
+
class App < Thor
|
|
26
|
+
def self.start(args = ARGV, **opts)
|
|
27
|
+
pastel = Pastel.new
|
|
28
|
+
$stdout.puts pastel.bright_magenta(LOGO)
|
|
29
|
+
$stdout.puts pastel.bright_magenta(" AI-powered SEO analysis") + "\n\n"
|
|
30
|
+
super
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
class_option :provider, type: :string, desc: "AI provider: anthropic, openai, gemini, ollama (overrides .seo.yml)"
|
|
34
|
+
class_option :key, type: :string, desc: "API key for the selected provider (overrides .seo.yml and env vars)"
|
|
35
|
+
class_option :model, type: :string, desc: "Model name to use (overrides provider default)"
|
|
36
|
+
|
|
37
|
+
desc "check [FILE]", "Analyse a file or entire project for SEO issues"
|
|
38
|
+
option :format, type: :string, default: "terminal", enum: %w[terminal json html], desc: "Output format"
|
|
39
|
+
def check(file = nil)
|
|
40
|
+
files = file ? [file] : Scanner.new(config).scan
|
|
41
|
+
if files.empty?
|
|
42
|
+
puts pastel.yellow("No template files found. Check your .seo.yml config.")
|
|
43
|
+
return
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
puts pastel.cyan("Analysing #{files.length} file(s)...")
|
|
47
|
+
|
|
48
|
+
if options[:format] == "terminal"
|
|
49
|
+
terminal = Output::Terminal.new
|
|
50
|
+
results = Analyser.new(provider: provider).analyse(files) { |r| terminal.render_result(r) }
|
|
51
|
+
terminal.render_summary(results)
|
|
52
|
+
else
|
|
53
|
+
results = Analyser.new(provider: provider).analyse(files)
|
|
54
|
+
puts Output::Reporter.new.render(results, format: options[:format])
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
desc "fix FILE", "Show a diff of suggested SEO fixes for a file and optionally apply them"
|
|
59
|
+
def fix(file)
|
|
60
|
+
abort(pastel.red("File not found: #{file}")) unless File.exist?(file)
|
|
61
|
+
|
|
62
|
+
puts pastel.cyan("Analysing #{file}...")
|
|
63
|
+
result = Analyser.new(provider: provider).fix(file)
|
|
64
|
+
|
|
65
|
+
unless result&.fixed_content
|
|
66
|
+
puts pastel.green("No fixes suggested for #{file}")
|
|
67
|
+
return
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
Output::Terminal.new.render_diff(result)
|
|
71
|
+
|
|
72
|
+
prompt = TTY::Prompt.new
|
|
73
|
+
if prompt.yes?("Apply these changes to #{file}?")
|
|
74
|
+
File.write(file, result.fixed_content)
|
|
75
|
+
puts pastel.green("✓ Changes applied to #{file}")
|
|
76
|
+
else
|
|
77
|
+
puts pastel.yellow("Changes not applied.")
|
|
78
|
+
end
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
desc "report", "Generate a full SEO report for the project"
|
|
82
|
+
option :format, type: :string, default: "terminal", enum: %w[terminal json html pdf], desc: "Output format"
|
|
83
|
+
option :output, type: :string, desc: "Write report to file (e.g. seo-report.html or seo-report.pdf)"
|
|
84
|
+
def report
|
|
85
|
+
files = Scanner.new(config).scan
|
|
86
|
+
if files.empty?
|
|
87
|
+
puts pastel.yellow("No template files found.")
|
|
88
|
+
return
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
if options[:format] == "pdf" && options[:output].nil?
|
|
92
|
+
abort pastel.red("--output is required for PDF format. Example: nerima report --format pdf --output seo-report.pdf")
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
puts pastel.cyan("Scanning #{files.length} files...")
|
|
96
|
+
done = 0
|
|
97
|
+
results = Analyser.new(provider: provider).analyse(files) do |r|
|
|
98
|
+
done += 1
|
|
99
|
+
puts pastel.dim(" [#{done}/#{files.length}] #{File.basename(r.file)}")
|
|
100
|
+
end
|
|
101
|
+
Output::Terminal.new.render_summary(results)
|
|
102
|
+
|
|
103
|
+
if options[:output]
|
|
104
|
+
if options[:format] == "pdf"
|
|
105
|
+
Output::Reporter.new.render_pdf(results, output_path: options[:output])
|
|
106
|
+
else
|
|
107
|
+
fmt = options[:format] == "terminal" ? "html" : options[:format]
|
|
108
|
+
File.write(options[:output], Output::Reporter.new.render(results, format: fmt))
|
|
109
|
+
end
|
|
110
|
+
puts pastel.green("✓ Report written to #{options[:output]}")
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
desc "init", "Create a .seo.yml config file in the current directory"
|
|
115
|
+
def init
|
|
116
|
+
if File.exist?(".seo.yml")
|
|
117
|
+
puts pastel.yellow(".seo.yml already exists.")
|
|
118
|
+
return
|
|
119
|
+
end
|
|
120
|
+
Seo::CLI::Config.write_default
|
|
121
|
+
puts pastel.green("✓ Created .seo.yml — edit it to configure which files to scan.")
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
private
|
|
125
|
+
|
|
126
|
+
def provider
|
|
127
|
+
provider_name = options[:provider] || config["provider"] || "anthropic"
|
|
128
|
+
api_key = options[:key] || env_key_for(provider_name) || config["api_key"] ||
|
|
129
|
+
config["anthropic_api_key"] # backward compat
|
|
130
|
+
|
|
131
|
+
if api_key.nil? && provider_name != "ollama"
|
|
132
|
+
abort pastel.red("No API key found for '#{provider_name}'. Set --key, the relevant env var, or add api_key to .seo.yml")
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
Providers.build(
|
|
136
|
+
provider_name: provider_name,
|
|
137
|
+
api_key: api_key,
|
|
138
|
+
model: options[:model] || config["model"]
|
|
139
|
+
)
|
|
140
|
+
rescue ArgumentError => e
|
|
141
|
+
abort pastel.red(e.message)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def env_key_for(provider_name)
|
|
145
|
+
env_var = {
|
|
146
|
+
"anthropic" => "ANTHROPIC_API_KEY",
|
|
147
|
+
"openai" => "OPENAI_API_KEY",
|
|
148
|
+
"gemini" => "GEMINI_API_KEY"
|
|
149
|
+
}[provider_name.to_s]
|
|
150
|
+
ENV[env_var] if env_var
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def config
|
|
154
|
+
@config ||= Seo::CLI::Config.load
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def pastel
|
|
158
|
+
@pastel ||= Pastel.new
|
|
159
|
+
end
|
|
160
|
+
end
|
|
161
|
+
end
|
|
162
|
+
end
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prawn"
|
|
4
|
+
require "prawn/table"
|
|
5
|
+
|
|
6
|
+
module Seo
|
|
7
|
+
module Output
|
|
8
|
+
class Reporter
|
|
9
|
+
INDIGO = "4f46e5"
|
|
10
|
+
RED = "dc2626"
|
|
11
|
+
AMBER = "d97706"
|
|
12
|
+
GREEN = "16a34a"
|
|
13
|
+
SLATE = "475569"
|
|
14
|
+
LIGHT = "f1f5f9"
|
|
15
|
+
WHITE = "ffffff"
|
|
16
|
+
|
|
17
|
+
def render(results, format:)
|
|
18
|
+
case format.to_s
|
|
19
|
+
when "json" then render_json(results)
|
|
20
|
+
when "html" then render_html(results)
|
|
21
|
+
else raise ArgumentError, "Unknown format: #{format}"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def render_pdf(results, output_path:)
|
|
26
|
+
avg_score = results.sum(&:score) / [results.length, 1].max
|
|
27
|
+
total_issues = results.sum { |r| r.issues.length }
|
|
28
|
+
clean_count = results.count { |r| r.issues.empty? }
|
|
29
|
+
|
|
30
|
+
Prawn::Document.generate(output_path, page_size: "A4", margin: [36, 40, 36, 40]) do |pdf|
|
|
31
|
+
# ── Header ───────────────────────────────────────────────────────
|
|
32
|
+
pdf.fill_color INDIGO
|
|
33
|
+
pdf.fill_rectangle [pdf.bounds.left, pdf.bounds.top + 36], pdf.bounds.width + 80, 70
|
|
34
|
+
pdf.fill_color WHITE
|
|
35
|
+
pdf.move_to [pdf.bounds.left, pdf.bounds.top + 20]
|
|
36
|
+
pdf.font_size(18) { pdf.text "SEO Report", style: :bold }
|
|
37
|
+
pdf.font_size(9) { pdf.text "Generated #{Time.now.strftime("%d %b %Y %H:%M")} · #{results.length} files scanned", color: "c7d2fe" }
|
|
38
|
+
pdf.move_down 20
|
|
39
|
+
|
|
40
|
+
# ── Summary cards ────────────────────────────────────────────────
|
|
41
|
+
pdf.fill_color "000000"
|
|
42
|
+
summary_data = [
|
|
43
|
+
["Average Score", "#{avg_score}/100", score_color(avg_score)],
|
|
44
|
+
["Total Issues", total_issues.to_s, total_issues.zero? ? GREEN : RED],
|
|
45
|
+
["Clean Files", "#{clean_count}/#{results.length}", clean_count == results.length ? GREEN : AMBER]
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
card_width = (pdf.bounds.width - 20) / 3.0
|
|
49
|
+
card_height = 52
|
|
50
|
+
|
|
51
|
+
summary_data.each_with_index do |(label, value, color), i|
|
|
52
|
+
x = pdf.bounds.left + i * (card_width + 10)
|
|
53
|
+
y = pdf.cursor
|
|
54
|
+
|
|
55
|
+
pdf.fill_color LIGHT
|
|
56
|
+
pdf.fill_rounded_rectangle [x, y], card_width, card_height, 4
|
|
57
|
+
pdf.fill_color "94a3b8"
|
|
58
|
+
pdf.font_size(7) { pdf.draw_text label.upcase, at: [x + 10, y - 14] }
|
|
59
|
+
pdf.fill_color color
|
|
60
|
+
pdf.font_size(20) { pdf.draw_text value, at: [x + 10, y - 38], style: :bold }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
pdf.move_down card_height + 16
|
|
64
|
+
|
|
65
|
+
# ── File results ─────────────────────────────────────────────────
|
|
66
|
+
results.each do |result|
|
|
67
|
+
pdf.fill_color "000000"
|
|
68
|
+
|
|
69
|
+
# Card background
|
|
70
|
+
card_start_y = pdf.cursor
|
|
71
|
+
pdf.fill_color LIGHT
|
|
72
|
+
pdf.fill_rounded_rectangle [pdf.bounds.left, card_start_y], pdf.bounds.width, 10, 4
|
|
73
|
+
|
|
74
|
+
# File name
|
|
75
|
+
pdf.fill_color INDIGO
|
|
76
|
+
pdf.font_size(8) { pdf.font("Courier") { pdf.text result.file, style: :bold } }
|
|
77
|
+
pdf.move_down 4
|
|
78
|
+
|
|
79
|
+
if result.issues.empty?
|
|
80
|
+
pdf.fill_color GREEN
|
|
81
|
+
pdf.font_size(9) { pdf.text "✓ No issues found" }
|
|
82
|
+
else
|
|
83
|
+
result.issues.each do |issue|
|
|
84
|
+
sev_color = issue[:severity] == :critical ? RED : AMBER
|
|
85
|
+
pdf.fill_color sev_color
|
|
86
|
+
pdf.font_size(7.5) do
|
|
87
|
+
pdf.text "[#{issue[:severity].to_s.upcase}] [#{issue[:check]}] #{issue[:message]}", indent_paragraphs: 8
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
if issue[:ai_risk] && issue[:ai_risk] != "none"
|
|
91
|
+
risk_color = { "high" => RED, "medium" => AMBER, "low" => GREEN }.fetch(issue[:ai_risk], SLATE)
|
|
92
|
+
pdf.fill_color risk_color
|
|
93
|
+
pdf.font_size(7) { pdf.text " AI Overview risk: #{issue[:ai_risk].upcase}", indent_paragraphs: 16 }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
unless result.suggestions.empty?
|
|
98
|
+
pdf.move_down 3
|
|
99
|
+
pdf.fill_color SLATE
|
|
100
|
+
result.suggestions.each do |s|
|
|
101
|
+
pdf.font_size(7.5) { pdf.text "• #{s}", indent_paragraphs: 8 }
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
# Score
|
|
107
|
+
pdf.move_down 4
|
|
108
|
+
pdf.fill_color "94a3b8"
|
|
109
|
+
pdf.font_size(7) { pdf.text "Score: #{result.score}/100", align: :right, color: score_color(result.score) }
|
|
110
|
+
pdf.move_down 10
|
|
111
|
+
|
|
112
|
+
# Stretch card bg to actual height
|
|
113
|
+
card_height_actual = card_start_y - pdf.cursor
|
|
114
|
+
pdf.fill_color LIGHT
|
|
115
|
+
pdf.fill_rounded_rectangle [pdf.bounds.left, card_start_y], pdf.bounds.width, card_height_actual, 4
|
|
116
|
+
pdf.move_down 8
|
|
117
|
+
|
|
118
|
+
pdf.start_new_page if pdf.cursor < 80
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
private
|
|
124
|
+
|
|
125
|
+
def render_json(results)
|
|
126
|
+
JSON.pretty_generate(results.map(&:to_h))
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def render_html(results)
|
|
130
|
+
avg_score = results.sum(&:score) / [results.length, 1].max
|
|
131
|
+
total_issues = results.sum { |r| r.issues.length }
|
|
132
|
+
|
|
133
|
+
<<~HTML
|
|
134
|
+
<!DOCTYPE html>
|
|
135
|
+
<html lang="en">
|
|
136
|
+
<head>
|
|
137
|
+
<meta charset="UTF-8">
|
|
138
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
139
|
+
<title>SEO Report</title>
|
|
140
|
+
<style>
|
|
141
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
142
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; background: #f8fafc; color: #1e293b; }
|
|
143
|
+
.header { background: #4f46e5; color: white; padding: 2rem; }
|
|
144
|
+
.header h1 { font-size: 1.5rem; font-weight: 700; }
|
|
145
|
+
.header p { opacity: 0.8; margin-top: 0.25rem; font-size: 0.875rem; }
|
|
146
|
+
.summary { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1rem; padding: 1.5rem; }
|
|
147
|
+
.card { background: white; border-radius: 0.75rem; padding: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
148
|
+
.card .label { font-size: 0.75rem; text-transform: uppercase; color: #94a3b8; font-weight: 600; }
|
|
149
|
+
.card .value { font-size: 2rem; font-weight: 700; margin-top: 0.25rem; }
|
|
150
|
+
.score-high { color: #16a34a; }
|
|
151
|
+
.score-mid { color: #d97706; }
|
|
152
|
+
.score-low { color: #dc2626; }
|
|
153
|
+
.files { padding: 0 1.5rem 1.5rem; display: grid; gap: 1rem; }
|
|
154
|
+
.file-card { background: white; border-radius: 0.75rem; padding: 1.25rem; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
|
|
155
|
+
.file-name { font-weight: 600; font-family: monospace; font-size: 0.875rem; margin-bottom: 0.75rem; }
|
|
156
|
+
.issue { display: flex; gap: 0.5rem; align-items: flex-start; padding: 0.4rem 0; font-size: 0.875rem; border-bottom: 1px solid #f1f5f9; }
|
|
157
|
+
.issue:last-child { border-bottom: none; }
|
|
158
|
+
.badge { font-size: 0.65rem; font-weight: 700; padding: 0.15rem 0.4rem; border-radius: 9999px; white-space: nowrap; }
|
|
159
|
+
.critical { background: #fee2e2; color: #dc2626; }
|
|
160
|
+
.warning { background: #fef3c7; color: #d97706; }
|
|
161
|
+
.check-badge { background: #e0e7ff; color: #4f46e5; }
|
|
162
|
+
.suggestions { margin-top: 0.75rem; padding-top: 0.75rem; border-top: 1px solid #f1f5f9; }
|
|
163
|
+
.suggestions li { font-size: 0.8125rem; color: #475569; padding: 0.2rem 0 0.2rem 1rem; list-style: disc; }
|
|
164
|
+
.ai-risk { display: inline-flex; align-items: center; gap: 0.4rem; font-size: 0.75rem; font-weight: 600; margin-top: 0.5rem; }
|
|
165
|
+
.risk-high { color: #dc2626; }
|
|
166
|
+
.risk-medium { color: #d97706; }
|
|
167
|
+
.risk-low { color: #16a34a; }
|
|
168
|
+
.clean { color: #16a34a; font-size: 0.875rem; }
|
|
169
|
+
</style>
|
|
170
|
+
</head>
|
|
171
|
+
<body>
|
|
172
|
+
<div class="header">
|
|
173
|
+
<h1>SEO Report</h1>
|
|
174
|
+
<p>Generated #{Time.now.strftime("%d %b %Y %H:%M")} · #{results.length} files scanned</p>
|
|
175
|
+
</div>
|
|
176
|
+
|
|
177
|
+
<div class="summary">
|
|
178
|
+
<div class="card">
|
|
179
|
+
<div class="label">Average Score</div>
|
|
180
|
+
<div class="value #{score_class(avg_score)}">#{avg_score}/100</div>
|
|
181
|
+
</div>
|
|
182
|
+
<div class="card">
|
|
183
|
+
<div class="label">Total Issues</div>
|
|
184
|
+
<div class="value #{total_issues.zero? ? "score-high" : "score-low"}">#{total_issues}</div>
|
|
185
|
+
</div>
|
|
186
|
+
<div class="card">
|
|
187
|
+
<div class="label">Files Scanned</div>
|
|
188
|
+
<div class="value">#{results.length}</div>
|
|
189
|
+
</div>
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<div class="files">
|
|
193
|
+
#{results.map { |r| file_card(r) }.join("\n")}
|
|
194
|
+
</div>
|
|
195
|
+
</body>
|
|
196
|
+
</html>
|
|
197
|
+
HTML
|
|
198
|
+
end
|
|
199
|
+
|
|
200
|
+
def file_card(result)
|
|
201
|
+
issues_html = if result.issues.empty?
|
|
202
|
+
'<p class="clean">✓ No issues found</p>'
|
|
203
|
+
else
|
|
204
|
+
result.issues.map do |i|
|
|
205
|
+
ai_note = ""
|
|
206
|
+
if i[:ai_risk] && i[:ai_risk] != "none"
|
|
207
|
+
ai_note = "<div class=\"ai-risk risk-#{i[:ai_risk]}\">AI Overview risk: #{i[:ai_risk].upcase}</div>"
|
|
208
|
+
end
|
|
209
|
+
"<div class=\"issue\"><span class=\"badge #{i[:severity]}\">#{i[:severity]}</span><span class=\"badge check-badge\">#{i[:check]}</span><span>#{i[:message]}</span>#{ai_note}</div>"
|
|
210
|
+
end.join
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
suggestions_html = unless result.suggestions.empty?
|
|
214
|
+
"<div class=\"suggestions\"><ul>#{result.suggestions.map { |s| "<li>#{s}</li>" }.join}</ul></div>"
|
|
215
|
+
end
|
|
216
|
+
|
|
217
|
+
score_html = "<div style='text-align:right;font-size:0.75rem;color:#94a3b8;margin-top:0.5rem;'>Score: <strong class='#{score_class(result.score)}'>#{result.score}/100</strong></div>"
|
|
218
|
+
|
|
219
|
+
"<div class=\"file-card\"><div class=\"file-name\">#{result.file}</div>#{issues_html}#{suggestions_html}#{score_html}</div>"
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
def score_class(score)
|
|
223
|
+
score >= 80 ? "score-high" : score >= 50 ? "score-mid" : "score-low"
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
def score_color(score)
|
|
227
|
+
score >= 80 ? GREEN : score >= 50 ? AMBER : RED
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
end
|
|
231
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pastel"
|
|
4
|
+
require "diffy"
|
|
5
|
+
|
|
6
|
+
module Seo
|
|
7
|
+
module Output
|
|
8
|
+
class Terminal
|
|
9
|
+
def initialize
|
|
10
|
+
@pastel = Pastel.new
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def render(results)
|
|
14
|
+
results.each { |r| render_result(r) }
|
|
15
|
+
render_summary(results)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def render_result(result)
|
|
19
|
+
puts ""
|
|
20
|
+
puts @pastel.bold(result.file)
|
|
21
|
+
puts @pastel.dim("─" * 60)
|
|
22
|
+
|
|
23
|
+
if result.issues.empty?
|
|
24
|
+
puts @pastel.green(" ✓ No issues found")
|
|
25
|
+
else
|
|
26
|
+
result.critical.each do |issue|
|
|
27
|
+
line_ref = issue[:line] ? @pastel.dim(" (line #{issue[:line]})") : ""
|
|
28
|
+
puts " #{@pastel.red("✖")} #{@pastel.bold("[#{issue[:check]}]")} #{issue[:message]}#{line_ref}"
|
|
29
|
+
end
|
|
30
|
+
result.warnings.each do |issue|
|
|
31
|
+
line_ref = issue[:line] ? @pastel.dim(" (line #{issue[:line]})") : ""
|
|
32
|
+
puts " #{@pastel.yellow("⚠")} #{@pastel.bold("[#{issue[:check]}]")} #{issue[:message]}#{line_ref}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
if result.issues.any? && result.issues.first[:ai_risk] && result.issues.first[:ai_risk] != "none"
|
|
37
|
+
risk = result.issues.first[:ai_risk]
|
|
38
|
+
colour = { "low" => :green, "medium" => :yellow, "high" => :red }.fetch(risk, :white)
|
|
39
|
+
puts ""
|
|
40
|
+
puts " #{@pastel.bold("AI Overview risk:")} #{@pastel.send(colour, risk.upcase)}"
|
|
41
|
+
puts @pastel.dim(" #{result.issues.first[:ai_risk_reason]}")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
unless result.suggestions.empty?
|
|
45
|
+
puts ""
|
|
46
|
+
puts @pastel.cyan(" Suggestions:")
|
|
47
|
+
result.suggestions.each { |s| puts " • #{s}" }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
puts " #{@pastel.dim("Score:")} #{score_colour(result.score)}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def render_diff(result)
|
|
54
|
+
puts ""
|
|
55
|
+
puts @pastel.bold("Suggested changes for #{result.file}:")
|
|
56
|
+
puts ""
|
|
57
|
+
|
|
58
|
+
diff = Diffy::Diff.new(
|
|
59
|
+
File.read(result.file),
|
|
60
|
+
result.fixed_content,
|
|
61
|
+
context: 3
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
diff.each_chunk do |chunk|
|
|
65
|
+
chunk.each_line do |line|
|
|
66
|
+
case line[0]
|
|
67
|
+
when "+"
|
|
68
|
+
print @pastel.green(line)
|
|
69
|
+
when "-"
|
|
70
|
+
print @pastel.red(line)
|
|
71
|
+
else
|
|
72
|
+
print @pastel.dim(line)
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
puts ""
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def render_summary(results)
|
|
80
|
+
total = results.length
|
|
81
|
+
clean = results.count { |r| r.issues.empty? }
|
|
82
|
+
issues = results.sum { |r| r.issues.length }
|
|
83
|
+
avg = results.sum(&:score) / [total, 1].max
|
|
84
|
+
|
|
85
|
+
puts ""
|
|
86
|
+
puts @pastel.bold("─" * 60)
|
|
87
|
+
puts @pastel.bold("Summary")
|
|
88
|
+
puts " Files scanned: #{total}"
|
|
89
|
+
puts " Clean files: #{@pastel.green(clean.to_s)}"
|
|
90
|
+
puts " Total issues: #{issues.positive? ? @pastel.red(issues.to_s) : @pastel.green("0")}"
|
|
91
|
+
puts " Average score: #{score_colour(avg)}"
|
|
92
|
+
|
|
93
|
+
high_risk = results.select { |r| r.issues.any? { |i| i[:ai_risk] == "high" } }
|
|
94
|
+
unless high_risk.empty?
|
|
95
|
+
puts ""
|
|
96
|
+
puts @pastel.red(" High AI Overview risk (#{high_risk.length} files):")
|
|
97
|
+
high_risk.each { |r| puts " • #{r.file}" }
|
|
98
|
+
end
|
|
99
|
+
puts ""
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
private
|
|
103
|
+
|
|
104
|
+
def score_colour(score)
|
|
105
|
+
colour = score >= 80 ? :green : score >= 50 ? :yellow : :red
|
|
106
|
+
@pastel.send(colour, "#{score}/100")
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "anthropic"
|
|
4
|
+
require_relative "base"
|
|
5
|
+
|
|
6
|
+
module Seo
|
|
7
|
+
module Providers
|
|
8
|
+
class Anthropic < Base
|
|
9
|
+
DEFAULT_MODEL = "claude-sonnet-4-6"
|
|
10
|
+
|
|
11
|
+
def initialize(api_key:, model: DEFAULT_MODEL)
|
|
12
|
+
@client = ::Anthropic::Client.new(access_token: api_key)
|
|
13
|
+
@model = model
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def complete(prompt, max_tokens:)
|
|
17
|
+
response = @client.messages(parameters: {
|
|
18
|
+
model: @model,
|
|
19
|
+
max_tokens: max_tokens,
|
|
20
|
+
messages: [{ role: "user", content: prompt }]
|
|
21
|
+
})
|
|
22
|
+
response.dig("content", 0, "text").to_s
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "base"
|
|
7
|
+
|
|
8
|
+
module Seo
|
|
9
|
+
module Providers
|
|
10
|
+
class Gemini < Base
|
|
11
|
+
DEFAULT_MODEL = "gemini-1.5-pro"
|
|
12
|
+
BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models"
|
|
13
|
+
|
|
14
|
+
def initialize(api_key:, model: DEFAULT_MODEL)
|
|
15
|
+
@api_key = api_key
|
|
16
|
+
@model = model
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def complete(prompt, max_tokens:)
|
|
20
|
+
uri = URI("#{BASE_URL}/#{@model}:generateContent?key=#{@api_key}")
|
|
21
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
22
|
+
http.use_ssl = true
|
|
23
|
+
|
|
24
|
+
req = Net::HTTP::Post.new(uri)
|
|
25
|
+
req["Content-Type"] = "application/json"
|
|
26
|
+
req.body = JSON.generate({
|
|
27
|
+
contents: [{ parts: [{ text: prompt }] }],
|
|
28
|
+
generationConfig: { maxOutputTokens: max_tokens }
|
|
29
|
+
})
|
|
30
|
+
|
|
31
|
+
data = JSON.parse(http.request(req).body)
|
|
32
|
+
data.dig("candidates", 0, "content", "parts", 0, "text").to_s
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "base"
|
|
7
|
+
|
|
8
|
+
module Seo
|
|
9
|
+
module Providers
|
|
10
|
+
class Ollama < Base
|
|
11
|
+
DEFAULT_MODEL = "llama3"
|
|
12
|
+
DEFAULT_HOST = "http://localhost:11434"
|
|
13
|
+
|
|
14
|
+
def initialize(api_key: nil, model: DEFAULT_MODEL, host: DEFAULT_HOST)
|
|
15
|
+
@model = model
|
|
16
|
+
@host = host
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def complete(prompt, max_tokens:)
|
|
20
|
+
uri = URI("#{@host}/api/chat")
|
|
21
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
22
|
+
http.use_ssl = uri.scheme == "https"
|
|
23
|
+
|
|
24
|
+
req = Net::HTTP::Post.new(uri)
|
|
25
|
+
req["Content-Type"] = "application/json"
|
|
26
|
+
req.body = JSON.generate({
|
|
27
|
+
model: @model,
|
|
28
|
+
stream: false,
|
|
29
|
+
messages: [{ role: "user", content: prompt }]
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
data = JSON.parse(http.request(req).body)
|
|
33
|
+
data.dig("message", "content").to_s
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "net/http"
|
|
4
|
+
require "json"
|
|
5
|
+
require "uri"
|
|
6
|
+
require_relative "base"
|
|
7
|
+
|
|
8
|
+
module Seo
|
|
9
|
+
module Providers
|
|
10
|
+
class OpenAI < Base
|
|
11
|
+
DEFAULT_MODEL = "gpt-4o"
|
|
12
|
+
API_URL = "https://api.openai.com/v1/chat/completions"
|
|
13
|
+
|
|
14
|
+
def initialize(api_key:, model: DEFAULT_MODEL)
|
|
15
|
+
@api_key = api_key
|
|
16
|
+
@model = model
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def complete(prompt, max_tokens:)
|
|
20
|
+
uri = URI(API_URL)
|
|
21
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
22
|
+
http.use_ssl = true
|
|
23
|
+
|
|
24
|
+
req = Net::HTTP::Post.new(uri)
|
|
25
|
+
req["Authorization"] = "Bearer #{@api_key}"
|
|
26
|
+
req["Content-Type"] = "application/json"
|
|
27
|
+
req.body = JSON.generate({
|
|
28
|
+
model: @model,
|
|
29
|
+
max_tokens: max_tokens,
|
|
30
|
+
messages: [{ role: "user", content: prompt }]
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
data = JSON.parse(http.request(req).body)
|
|
34
|
+
data.dig("choices", 0, "message", "content").to_s
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "providers/base"
|
|
4
|
+
require_relative "providers/anthropic"
|
|
5
|
+
require_relative "providers/openai"
|
|
6
|
+
require_relative "providers/gemini"
|
|
7
|
+
require_relative "providers/ollama"
|
|
8
|
+
|
|
9
|
+
module Seo
|
|
10
|
+
module Providers
|
|
11
|
+
REGISTRY = {
|
|
12
|
+
"anthropic" => Anthropic,
|
|
13
|
+
"openai" => OpenAI,
|
|
14
|
+
"gemini" => Gemini,
|
|
15
|
+
"ollama" => Ollama
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def self.build(provider_name:, api_key: nil, model: nil, host: nil)
|
|
19
|
+
klass = REGISTRY[provider_name.to_s.downcase]
|
|
20
|
+
raise ArgumentError, "Unknown provider '#{provider_name}'. Supported: #{REGISTRY.keys.join(", ")}" unless klass
|
|
21
|
+
|
|
22
|
+
args = { api_key: api_key }
|
|
23
|
+
args[:model] = model if model
|
|
24
|
+
args[:host] = host if host && provider_name.to_s == "ollama"
|
|
25
|
+
klass.new(**args)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.names
|
|
29
|
+
REGISTRY.keys
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
data/lib/seo/result.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Seo
|
|
4
|
+
Result = Struct.new(:file, :issues, :suggestions, :fixed_content, keyword_init: true) do
|
|
5
|
+
def score
|
|
6
|
+
return 100 if issues.empty?
|
|
7
|
+
[100 - (issues.length * 10), 0].max
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def critical
|
|
11
|
+
issues.select { |i| i[:severity] == :critical }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def warnings
|
|
15
|
+
issues.select { |i| i[:severity] == :warning }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def to_h
|
|
19
|
+
{
|
|
20
|
+
file: file,
|
|
21
|
+
score: score,
|
|
22
|
+
issues: issues,
|
|
23
|
+
suggestions: suggestions
|
|
24
|
+
}
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/seo/scanner.rb
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "pathname"
|
|
4
|
+
|
|
5
|
+
module Seo
|
|
6
|
+
class Scanner
|
|
7
|
+
def initialize(config)
|
|
8
|
+
@include_patterns = config["include"] || []
|
|
9
|
+
@exclude_patterns = config["exclude"] || []
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def scan(root = Dir.pwd)
|
|
13
|
+
files = @include_patterns.flat_map do |pattern|
|
|
14
|
+
Dir.glob(File.join(root, pattern))
|
|
15
|
+
end.uniq.sort
|
|
16
|
+
|
|
17
|
+
files.reject do |file|
|
|
18
|
+
@exclude_patterns.any? do |pattern|
|
|
19
|
+
File.fnmatch?(File.join(root, pattern), file, File::FNM_PATHNAME)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
metadata
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: nerima
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: ruby
|
|
6
|
+
authors:
|
|
7
|
+
- Tom Dringer
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 2026-05-30 00:00:00.000000000 Z
|
|
11
|
+
dependencies:
|
|
12
|
+
- !ruby/object:Gem::Dependency
|
|
13
|
+
name: thor
|
|
14
|
+
requirement: !ruby/object:Gem::Requirement
|
|
15
|
+
requirements:
|
|
16
|
+
- - "~>"
|
|
17
|
+
- !ruby/object:Gem::Version
|
|
18
|
+
version: '1.3'
|
|
19
|
+
type: :runtime
|
|
20
|
+
prerelease: false
|
|
21
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
22
|
+
requirements:
|
|
23
|
+
- - "~>"
|
|
24
|
+
- !ruby/object:Gem::Version
|
|
25
|
+
version: '1.3'
|
|
26
|
+
- !ruby/object:Gem::Dependency
|
|
27
|
+
name: ruby-anthropic
|
|
28
|
+
requirement: !ruby/object:Gem::Requirement
|
|
29
|
+
requirements:
|
|
30
|
+
- - "~>"
|
|
31
|
+
- !ruby/object:Gem::Version
|
|
32
|
+
version: '0.4'
|
|
33
|
+
type: :runtime
|
|
34
|
+
prerelease: false
|
|
35
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
36
|
+
requirements:
|
|
37
|
+
- - "~>"
|
|
38
|
+
- !ruby/object:Gem::Version
|
|
39
|
+
version: '0.4'
|
|
40
|
+
- !ruby/object:Gem::Dependency
|
|
41
|
+
name: nokogiri
|
|
42
|
+
requirement: !ruby/object:Gem::Requirement
|
|
43
|
+
requirements:
|
|
44
|
+
- - "~>"
|
|
45
|
+
- !ruby/object:Gem::Version
|
|
46
|
+
version: '1.16'
|
|
47
|
+
type: :runtime
|
|
48
|
+
prerelease: false
|
|
49
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
50
|
+
requirements:
|
|
51
|
+
- - "~>"
|
|
52
|
+
- !ruby/object:Gem::Version
|
|
53
|
+
version: '1.16'
|
|
54
|
+
- !ruby/object:Gem::Dependency
|
|
55
|
+
name: diffy
|
|
56
|
+
requirement: !ruby/object:Gem::Requirement
|
|
57
|
+
requirements:
|
|
58
|
+
- - "~>"
|
|
59
|
+
- !ruby/object:Gem::Version
|
|
60
|
+
version: '3.4'
|
|
61
|
+
type: :runtime
|
|
62
|
+
prerelease: false
|
|
63
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
64
|
+
requirements:
|
|
65
|
+
- - "~>"
|
|
66
|
+
- !ruby/object:Gem::Version
|
|
67
|
+
version: '3.4'
|
|
68
|
+
- !ruby/object:Gem::Dependency
|
|
69
|
+
name: tty-prompt
|
|
70
|
+
requirement: !ruby/object:Gem::Requirement
|
|
71
|
+
requirements:
|
|
72
|
+
- - "~>"
|
|
73
|
+
- !ruby/object:Gem::Version
|
|
74
|
+
version: '0.23'
|
|
75
|
+
type: :runtime
|
|
76
|
+
prerelease: false
|
|
77
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
78
|
+
requirements:
|
|
79
|
+
- - "~>"
|
|
80
|
+
- !ruby/object:Gem::Version
|
|
81
|
+
version: '0.23'
|
|
82
|
+
- !ruby/object:Gem::Dependency
|
|
83
|
+
name: tty-color
|
|
84
|
+
requirement: !ruby/object:Gem::Requirement
|
|
85
|
+
requirements:
|
|
86
|
+
- - "~>"
|
|
87
|
+
- !ruby/object:Gem::Version
|
|
88
|
+
version: '0.6'
|
|
89
|
+
type: :runtime
|
|
90
|
+
prerelease: false
|
|
91
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
92
|
+
requirements:
|
|
93
|
+
- - "~>"
|
|
94
|
+
- !ruby/object:Gem::Version
|
|
95
|
+
version: '0.6'
|
|
96
|
+
- !ruby/object:Gem::Dependency
|
|
97
|
+
name: pastel
|
|
98
|
+
requirement: !ruby/object:Gem::Requirement
|
|
99
|
+
requirements:
|
|
100
|
+
- - "~>"
|
|
101
|
+
- !ruby/object:Gem::Version
|
|
102
|
+
version: '0.8'
|
|
103
|
+
type: :runtime
|
|
104
|
+
prerelease: false
|
|
105
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
106
|
+
requirements:
|
|
107
|
+
- - "~>"
|
|
108
|
+
- !ruby/object:Gem::Version
|
|
109
|
+
version: '0.8'
|
|
110
|
+
- !ruby/object:Gem::Dependency
|
|
111
|
+
name: json
|
|
112
|
+
requirement: !ruby/object:Gem::Requirement
|
|
113
|
+
requirements:
|
|
114
|
+
- - "~>"
|
|
115
|
+
- !ruby/object:Gem::Version
|
|
116
|
+
version: '2.7'
|
|
117
|
+
type: :runtime
|
|
118
|
+
prerelease: false
|
|
119
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
120
|
+
requirements:
|
|
121
|
+
- - "~>"
|
|
122
|
+
- !ruby/object:Gem::Version
|
|
123
|
+
version: '2.7'
|
|
124
|
+
- !ruby/object:Gem::Dependency
|
|
125
|
+
name: prawn
|
|
126
|
+
requirement: !ruby/object:Gem::Requirement
|
|
127
|
+
requirements:
|
|
128
|
+
- - "~>"
|
|
129
|
+
- !ruby/object:Gem::Version
|
|
130
|
+
version: '2.5'
|
|
131
|
+
type: :runtime
|
|
132
|
+
prerelease: false
|
|
133
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
134
|
+
requirements:
|
|
135
|
+
- - "~>"
|
|
136
|
+
- !ruby/object:Gem::Version
|
|
137
|
+
version: '2.5'
|
|
138
|
+
- !ruby/object:Gem::Dependency
|
|
139
|
+
name: prawn-table
|
|
140
|
+
requirement: !ruby/object:Gem::Requirement
|
|
141
|
+
requirements:
|
|
142
|
+
- - "~>"
|
|
143
|
+
- !ruby/object:Gem::Version
|
|
144
|
+
version: '0.2'
|
|
145
|
+
type: :runtime
|
|
146
|
+
prerelease: false
|
|
147
|
+
version_requirements: !ruby/object:Gem::Requirement
|
|
148
|
+
requirements:
|
|
149
|
+
- - "~>"
|
|
150
|
+
- !ruby/object:Gem::Version
|
|
151
|
+
version: '0.2'
|
|
152
|
+
description: AI-powered SEO analysis for developers. Scan your templates, spot issues,
|
|
153
|
+
and fix them — without ever leaving your terminal.
|
|
154
|
+
executables:
|
|
155
|
+
- nerima
|
|
156
|
+
extensions: []
|
|
157
|
+
extra_rdoc_files: []
|
|
158
|
+
files:
|
|
159
|
+
- exe/nerima
|
|
160
|
+
- lib/seo/analyser.rb
|
|
161
|
+
- lib/seo/cli.rb
|
|
162
|
+
- lib/seo/cli/config.rb
|
|
163
|
+
- lib/seo/cli/version.rb
|
|
164
|
+
- lib/seo/output/reporter.rb
|
|
165
|
+
- lib/seo/output/terminal.rb
|
|
166
|
+
- lib/seo/providers.rb
|
|
167
|
+
- lib/seo/providers/anthropic.rb
|
|
168
|
+
- lib/seo/providers/base.rb
|
|
169
|
+
- lib/seo/providers/gemini.rb
|
|
170
|
+
- lib/seo/providers/ollama.rb
|
|
171
|
+
- lib/seo/providers/openai.rb
|
|
172
|
+
- lib/seo/result.rb
|
|
173
|
+
- lib/seo/scanner.rb
|
|
174
|
+
homepage: https://nerimasoft.co.uk/nerima.html
|
|
175
|
+
licenses:
|
|
176
|
+
- MIT
|
|
177
|
+
metadata: {}
|
|
178
|
+
rdoc_options: []
|
|
179
|
+
require_paths:
|
|
180
|
+
- lib
|
|
181
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
182
|
+
requirements:
|
|
183
|
+
- - ">="
|
|
184
|
+
- !ruby/object:Gem::Version
|
|
185
|
+
version: 3.1.0
|
|
186
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
187
|
+
requirements:
|
|
188
|
+
- - ">="
|
|
189
|
+
- !ruby/object:Gem::Version
|
|
190
|
+
version: '0'
|
|
191
|
+
requirements: []
|
|
192
|
+
rubygems_version: 3.6.2
|
|
193
|
+
specification_version: 4
|
|
194
|
+
summary: AI-powered SEO analysis for developers
|
|
195
|
+
test_files: []
|