sources.skip_rules_file names a JSON file of filters, each with a
comment, an active flag, a logic mode ("or" by default, or "and") and
rules by buyer, title words or CPV codes. The fetcher applies them
before saving, so only new opportunities are marked; the marks of
stored ones are never touched. CPV rules match when the opportunity's
codes are all in the list, or any of them with exclusive false, and
by prefix with exact false.
The file is read again when its time or size changes: an invalid edit
is logged and the last good rules kept, and a deleted file means no
rules until it's back. fetcher service and serve refuse to start if it
is define but missing or invalid.
job-search skip-rules check validates a rules file, and skip-rules
apply marks the stored opportunities that match Skipped, or only lists
them with --dry-run.
150 lines
3.3 KiB
Go
150 lines
3.3 KiB
Go
package skiprules
|
|
|
|
import (
|
|
"errors"
|
|
"log/slog"
|
|
"os"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Option configures a File.
|
|
type Option func(*File)
|
|
|
|
// WithLogger makes the file log its reloads and problems to logger instead
|
|
// of slog.Default().
|
|
func WithLogger(logger *slog.Logger) Option {
|
|
return func(f *File) {
|
|
f.log = logger
|
|
}
|
|
}
|
|
|
|
// File is a rules file that is read again when it changes (see Rules).
|
|
// Create it with Open. It is safe for concurrent use.
|
|
type File struct {
|
|
path string
|
|
log *slog.Logger
|
|
|
|
mu sync.Mutex
|
|
// rules are the last good rules, or nil while the file is missing.
|
|
rules *Rules
|
|
// modTime and size are the file's when it was last read, good or not, so
|
|
// a bad file isn't read again until it changes.
|
|
modTime time.Time
|
|
size int64
|
|
// problem is the missing or unreadable file last logged, so it is
|
|
// logged once rather than on every call; "" once the file is found.
|
|
problem string
|
|
}
|
|
|
|
// Open reads the rules file at path. Unlike a reload, a missing or invalid
|
|
// file is an error.
|
|
func Open(path string, opts ...Option) (*File, error) {
|
|
file := &File{
|
|
path: path,
|
|
}
|
|
for _, option := range opts {
|
|
option(file)
|
|
}
|
|
|
|
info, err := os.Stat(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
rules, err := Load(path)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
file.rules = rules
|
|
file.modTime = info.ModTime()
|
|
file.size = info.Size()
|
|
|
|
return file, nil
|
|
}
|
|
|
|
// logger returns the logger the file logs to, tagged with its component.
|
|
func (f *File) logger() *slog.Logger {
|
|
log := f.log
|
|
if log == nil {
|
|
log = slog.Default()
|
|
}
|
|
return log.With("component", "skiprules")
|
|
}
|
|
|
|
// Path returns the path of the rules file.
|
|
func (f *File) Path() string { return f.path }
|
|
|
|
// Rules returns the current rules, first reading the file again if its
|
|
// modification time or size has changed since it was last read.
|
|
//
|
|
// If the file can't be read or parsed, the error is logged and the last good
|
|
// rules are kept. If it has been deleted, a warning is logged and the result
|
|
// is nil (no rules) until it is back. A file that is missing or can't be
|
|
// checked is logged once, not on every call; an invalid one once per change.
|
|
func (f *File) Rules() *Rules {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
|
|
info, err := os.Stat(f.path)
|
|
if errors.Is(err, os.ErrNotExist) {
|
|
f.rules = nil
|
|
f.modTime = time.Time{}
|
|
f.size = 0
|
|
if f.newProblem("missing") {
|
|
f.logger().Warn(
|
|
"skip rules file missing, skipping nothing", "file", f.path,
|
|
)
|
|
}
|
|
return nil
|
|
}
|
|
if err != nil {
|
|
if f.newProblem(err.Error()) {
|
|
f.logger().Error(
|
|
"skip rules file unreadable, keeping the last good rules",
|
|
"file", f.path,
|
|
"error", err,
|
|
)
|
|
}
|
|
return f.rules
|
|
}
|
|
|
|
f.problem = ""
|
|
if info.ModTime().Equal(f.modTime) && info.Size() == f.size {
|
|
return f.rules
|
|
}
|
|
|
|
f.modTime = info.ModTime()
|
|
f.size = info.Size()
|
|
rules, err := Load(f.path)
|
|
if err != nil {
|
|
f.logger().Error(
|
|
"skip rules file invalid, keeping the last good rules",
|
|
"file", f.path,
|
|
"error", err,
|
|
)
|
|
return f.rules
|
|
}
|
|
|
|
f.rules = rules
|
|
filters, active := rules.Filters()
|
|
f.logger().Info(
|
|
"skip rules reloaded",
|
|
"file", f.path,
|
|
"filters", filters,
|
|
"active_filters", active,
|
|
)
|
|
|
|
return f.rules
|
|
}
|
|
|
|
// newProblem reports whether problem isn't the one last logged, and
|
|
// remembers it. Call it with f.mu held.
|
|
func (f *File) newProblem(problem string) bool {
|
|
if problem == f.problem {
|
|
return false
|
|
}
|
|
f.problem = problem
|
|
return true
|
|
}
|