Cybersecurity-Projects/PROJECTS/intermediate/security-news-scraper/internal/config/config.go

237 lines
7.2 KiB
Go

// ©AngelaMos | 2026
// config.go
package config
import (
"fmt"
"os"
"gopkg.in/yaml.v3"
)
const (
defaultDBPath = "nadezhda.db"
defaultUserAgent = "nadezhda/0.1 (+https://github.com/CarterPerez-dev/nadezhda)"
defaultPerHostRate = 0.5
defaultPerHostBurst = 1
defaultTimeoutSeconds = 25
defaultSourceTimeoutSeconds = 90
defaultWorkers = 8
defaultMaxRetries = 3
defaultCacheTTLHours = 24
defaultNegativeTTLHours = 3
defaultTitleJaccard = 0.6
defaultWindowHours = 72
defaultLookbackHours = 168
trackingUTMPrefix = "utm_*"
defaultHalfLifeHours = 48
defaultVelocityNorm = 0.5
defaultWeightRecency = 0.30
defaultWeightVelocity = 0.20
defaultWeightKEV = 0.12
defaultWeightCVSS = 0.10
defaultWeightSource = 0.10
defaultWeightKeyword = 0.10
defaultWeightEPSS = 0.08
defaultAIProvider = "qwen"
defaultQwenBaseURL = "http://localhost:39847/v1"
defaultQwenModel = "qwen2.5:7b"
defaultOpenAIURL = "https://api.openai.com/v1"
defaultOpenAIModel = "gpt-4o-mini"
defaultGeminiURL = "https://generativelanguage.googleapis.com/v1beta/openai/"
defaultGeminiModel = "gemini-2.5-flash"
defaultAnthropicURL = "https://api.anthropic.com/v1"
defaultClaudeModel = "claude-sonnet-4-6"
)
var defaultTrackingParams = []string{
trackingUTMPrefix, "gclid", "fbclid", "ref", "mc_cid", "mc_eid",
}
type Fetch struct {
UserAgent string `yaml:"user_agent"`
PerHostRate float64 `yaml:"per_host_rate"`
PerHostBurst int `yaml:"per_host_burst"`
TimeoutSeconds int `yaml:"timeout_seconds"`
SourceTimeoutSeconds int `yaml:"source_timeout_seconds"`
Workers int `yaml:"workers"`
MaxRetries int `yaml:"max_retries"`
}
type Enrich struct {
CacheTTLHours int `yaml:"cache_ttl_hours"`
NegativeTTLHours int `yaml:"negative_ttl_hours"`
NVDAPIKey string `yaml:"nvd_api_key"`
}
type Cluster struct {
TitleJaccard float64 `yaml:"title_jaccard_threshold"`
WindowHours int `yaml:"window_hours"`
LookbackHours int `yaml:"lookback_hours"`
TrackingParams []string `yaml:"tracking_params"`
}
type Weights struct {
Recency float64 `yaml:"recency"`
CVSS float64 `yaml:"cvss"`
KEV float64 `yaml:"kev"`
EPSS float64 `yaml:"epss"`
Velocity float64 `yaml:"velocity"`
Source float64 `yaml:"source"`
Keyword float64 `yaml:"keyword"`
}
type Rank struct {
HalfLifeHours int `yaml:"half_life_hours"`
VelocityNorm float64 `yaml:"velocity_norm"`
Weights Weights `yaml:"weights"`
}
type Provider struct {
BaseURL string `yaml:"base_url"`
Model string `yaml:"model"`
}
type AI struct {
Enabled bool `yaml:"enabled"`
Provider string `yaml:"provider"`
Qwen Provider `yaml:"qwen"`
OpenAI Provider `yaml:"openai"`
Gemini Provider `yaml:"gemini"`
Anthropic Provider `yaml:"anthropic"`
}
type Config struct {
DBPath string `yaml:"db_path"`
SourcesPath string `yaml:"sources_path"`
Watchlist []string `yaml:"watchlist"`
Fetch Fetch `yaml:"fetch"`
Enrich Enrich `yaml:"enrich"`
Cluster Cluster `yaml:"cluster"`
Rank Rank `yaml:"rank"`
AI AI `yaml:"ai"`
}
func Default() Config {
return Config{
DBPath: defaultDBPath,
Fetch: Fetch{
UserAgent: defaultUserAgent,
PerHostRate: defaultPerHostRate,
PerHostBurst: defaultPerHostBurst,
TimeoutSeconds: defaultTimeoutSeconds,
SourceTimeoutSeconds: defaultSourceTimeoutSeconds,
Workers: defaultWorkers,
MaxRetries: defaultMaxRetries,
},
Enrich: Enrich{
CacheTTLHours: defaultCacheTTLHours,
NegativeTTLHours: defaultNegativeTTLHours,
},
Cluster: Cluster{
TitleJaccard: defaultTitleJaccard,
WindowHours: defaultWindowHours,
LookbackHours: defaultLookbackHours,
TrackingParams: defaultTrackingParams,
},
Rank: Rank{
HalfLifeHours: defaultHalfLifeHours,
VelocityNorm: defaultVelocityNorm,
Weights: Weights{
Recency: defaultWeightRecency,
CVSS: defaultWeightCVSS,
KEV: defaultWeightKEV,
EPSS: defaultWeightEPSS,
Velocity: defaultWeightVelocity,
Source: defaultWeightSource,
Keyword: defaultWeightKeyword,
},
},
AI: AI{
Enabled: false,
Provider: defaultAIProvider,
Qwen: Provider{BaseURL: defaultQwenBaseURL, Model: defaultQwenModel},
OpenAI: Provider{BaseURL: defaultOpenAIURL, Model: defaultOpenAIModel},
Gemini: Provider{BaseURL: defaultGeminiURL, Model: defaultGeminiModel},
Anthropic: Provider{BaseURL: defaultAnthropicURL, Model: defaultClaudeModel},
},
}
}
func Load(path string) (Config, error) {
cfg := Default()
if path == "" {
return cfg, nil
}
raw, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return cfg, nil
}
return Config{}, fmt.Errorf("read config %s: %w", path, err)
}
if err := yaml.Unmarshal(raw, &cfg); err != nil {
return Config{}, fmt.Errorf("parse config %s: %w", path, err)
}
if err := cfg.validate(); err != nil {
return Config{}, err
}
return cfg, nil
}
func (c Config) validate() error {
if c.DBPath == "" {
return fmt.Errorf("config: db_path must not be empty")
}
if c.Fetch.Workers < 1 {
return fmt.Errorf("config: fetch.workers must be >= 1, got %d", c.Fetch.Workers)
}
if c.Fetch.PerHostRate <= 0 {
return fmt.Errorf("config: fetch.per_host_rate must be > 0, got %v", c.Fetch.PerHostRate)
}
if c.Fetch.PerHostBurst < 1 {
return fmt.Errorf("config: fetch.per_host_burst must be >= 1, got %d", c.Fetch.PerHostBurst)
}
if c.Fetch.TimeoutSeconds < 1 {
return fmt.Errorf("config: fetch.timeout_seconds must be >= 1, got %d", c.Fetch.TimeoutSeconds)
}
if c.Fetch.SourceTimeoutSeconds < 1 {
return fmt.Errorf("config: fetch.source_timeout_seconds must be >= 1, got %d", c.Fetch.SourceTimeoutSeconds)
}
if c.Enrich.CacheTTLHours < 0 || c.Enrich.NegativeTTLHours < 0 {
return fmt.Errorf("config: enrich TTLs must be >= 0, got cache=%d negative=%d", c.Enrich.CacheTTLHours, c.Enrich.NegativeTTLHours)
}
if c.Cluster.TitleJaccard < 0 || c.Cluster.TitleJaccard > 1 {
return fmt.Errorf("config: cluster.title_jaccard_threshold must be in [0,1], got %v", c.Cluster.TitleJaccard)
}
if c.Cluster.WindowHours < 1 {
return fmt.Errorf("config: cluster.window_hours must be >= 1, got %d", c.Cluster.WindowHours)
}
if c.Cluster.LookbackHours < 1 {
return fmt.Errorf("config: cluster.lookback_hours must be >= 1, got %d", c.Cluster.LookbackHours)
}
if c.Cluster.LookbackHours < c.Cluster.WindowHours {
return fmt.Errorf("config: cluster.lookback_hours (%d) must be >= cluster.window_hours (%d) or window edges near the corpus boundary are silently dropped", c.Cluster.LookbackHours, c.Cluster.WindowHours)
}
if c.Rank.HalfLifeHours < 1 {
return fmt.Errorf("config: rank.half_life_hours must be >= 1, got %d", c.Rank.HalfLifeHours)
}
if c.Rank.VelocityNorm <= 0 {
return fmt.Errorf("config: rank.velocity_norm must be > 0, got %v", c.Rank.VelocityNorm)
}
switch c.AI.Provider {
case "qwen", "openai", "gemini", "anthropic":
default:
return fmt.Errorf("config: ai.provider must be one of qwen|openai|gemini|anthropic, got %q", c.AI.Provider)
}
return nil
}