Added skip rules, which mark new opportunities Skipped when they're fetched
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.
This commit is contained in:
1 parent
2e6c21f9dd
commit
0543762330
21 files changed
+1766
-8
No files matched your search
@@ -0,0 +1,174 @@
|
||||
package skiprules
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"git.avsdev.uk/Go/job-search/internal/cli"
|
||||
"git.avsdev.uk/Go/job-search/internal/skiprules"
|
||||
"git.avsdev.uk/Go/job-search/internal/store"
|
||||
"git.avsdev.uk/Go/job-search/internal/types"
|
||||
)
|
||||
|
||||
// applyPageSize is how many opportunities apply reads at a time.
|
||||
const applyPageSize = 500
|
||||
|
||||
// match is a stored opportunity the rules skip, and the filter that does.
|
||||
type match struct {
|
||||
opportunity types.Opportunity
|
||||
filter string
|
||||
}
|
||||
|
||||
// commandApply returns the "apply" command, which marks the stored
|
||||
// opportunities that match the rules Skipped.
|
||||
func commandApply(opts *cli.Options) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "apply [file]",
|
||||
Short: "Mark stored opportunities that match skip rules Skipped",
|
||||
Long: `Mark the stored opportunities that match the skip rules Skipped,
|
||||
listing each one. The rules are read from the file given, or else from
|
||||
sources.skip_rules_file. Only the Skipped mark is ever set, and never
|
||||
cleared: opportunities already skipped are left alone. With --dry-run, the
|
||||
matches are listed but nothing is changed.
|
||||
|
||||
Each change is recorded for clients to catch up from, but isn't pushed to
|
||||
open browsers as a running "serve" pushes its own changes.`,
|
||||
Args: cobra.MaximumNArgs(1),
|
||||
PersistentPreRun: func(cmd *cobra.Command, args []string) {
|
||||
slog.Debug("command persistent pre-run", "command", "skip-rules.apply")
|
||||
},
|
||||
PersistentPostRun: func(cmd *cobra.Command, args []string) {
|
||||
slog.Debug("command persistent post-run", "command", "skip-rules.apply")
|
||||
},
|
||||
RunE: func(cmd *cobra.Command, args []string) (err error) {
|
||||
slog.Debug("command run", "command", "skip-rules.apply")
|
||||
|
||||
dryRun, _ := cmd.Flags().GetBool("dry-run")
|
||||
|
||||
conf, err := opts.RunSetup(cmd, true, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("load configuration: %w", err)
|
||||
}
|
||||
|
||||
rules, _, err := loadRules(conf, args)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opportunityStore, err := cli.OpenConfiguredStore(cmd.Context(), conf)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() {
|
||||
err = errors.Join(err, opportunityStore.Close())
|
||||
}()
|
||||
|
||||
matches, err := findMatches(cmd.Context(), opportunityStore, rules)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = printMatches(cmd.OutOrStdout(), matches)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if dryRun {
|
||||
_, err = fmt.Fprintf(
|
||||
cmd.OutOrStdout(),
|
||||
"%d opportunities would be skipped (dry run)\n",
|
||||
len(matches),
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
err = skipAll(cmd.Context(), opportunityStore, matches)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = fmt.Fprintf(
|
||||
cmd.OutOrStdout(), "Skipped %d opportunities\n", len(matches),
|
||||
)
|
||||
return err
|
||||
},
|
||||
}
|
||||
cmd.Flags().Bool("dry-run", false, "list the matches without changing them")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
// findMatches returns the stored opportunities not yet skipped that rules
|
||||
// match, newest first. They are all read before any is changed, so changing
|
||||
// them doesn't move the pages being read.
|
||||
func findMatches(
|
||||
ctx context.Context, opportunityStore *store.Store, rules *skiprules.Rules,
|
||||
) ([]match, error) {
|
||||
var matches []match
|
||||
for offset := 0; ; offset += applyPageSize {
|
||||
page, err := opportunityStore.ListOpportunities(
|
||||
ctx,
|
||||
store.OpportunityFilter{
|
||||
Skipped: store.MarkUnset,
|
||||
Limit: applyPageSize,
|
||||
Offset: offset,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing opportunities: %w", err)
|
||||
}
|
||||
|
||||
for i := range page {
|
||||
filter, matched := rules.Match(&page[i])
|
||||
if matched {
|
||||
matches = append(matches, match{
|
||||
opportunity: page[i],
|
||||
filter: filter,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if len(page) < applyPageSize {
|
||||
return matches, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// printMatches lists matches, one per line: UUID, filter and title.
|
||||
func printMatches(out io.Writer, matches []match) error {
|
||||
table := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
|
||||
for _, found := range matches {
|
||||
_, err := fmt.Fprintf(
|
||||
table,
|
||||
"%s\t%s\t%s\n",
|
||||
found.opportunity.UUID,
|
||||
found.filter,
|
||||
found.opportunity.Title,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return table.Flush()
|
||||
}
|
||||
|
||||
// skipAll sets the Skipped mark of every match, keeping its other marks.
|
||||
func skipAll(
|
||||
ctx context.Context, opportunityStore *store.Store, matches []match,
|
||||
) error {
|
||||
for _, found := range matches {
|
||||
marks := found.opportunity.Marks
|
||||
marks.Skipped = true
|
||||
if err := opportunityStore.SetMarks(ctx, marks); err != nil {
|
||||
return fmt.Errorf(
|
||||
"skipping %s: %w", found.opportunity.UUID, err,
|
||||
)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
Reference in new issue
Block a user