feat: implement rule engine, worker pool, event bus, and readability scraping with enhanced logging and content sanitization.
This commit is contained in:
@@ -0,0 +1,82 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/db"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/events"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type WorkerDispatcher interface {
|
||||
Dispatch(ctx context.Context, action models.RuleAction, entry models.FeedEntry, depth int)
|
||||
}
|
||||
|
||||
type Engine struct {
|
||||
db *db.DB
|
||||
bus *events.EventBus
|
||||
evaluator *Evaluator
|
||||
dispatcher WorkerDispatcher
|
||||
log *zap.Logger
|
||||
}
|
||||
|
||||
func NewEngine(database *db.DB, bus *events.EventBus, dispatcher WorkerDispatcher) *Engine {
|
||||
return &Engine{
|
||||
db: database,
|
||||
bus: bus,
|
||||
evaluator: NewEvaluator(),
|
||||
dispatcher: dispatcher,
|
||||
log: logger.Named("rules-engine"),
|
||||
}
|
||||
}
|
||||
|
||||
// Start registers event bus subscriptions to evaluate rules reactively.
|
||||
func (e *Engine) Start(ctx context.Context) {
|
||||
e.bus.Subscribe(events.EventPostCreated, e.handleEvent)
|
||||
e.bus.Subscribe(events.EventPostUpdated, e.handleEvent)
|
||||
e.bus.Subscribe(events.EventVariantUpdated, e.handleEvent)
|
||||
e.bus.Subscribe(events.EventManualTrigger, e.handleEvent)
|
||||
e.log.Info("Rules engine started, listening for events")
|
||||
}
|
||||
|
||||
func (e *Engine) handleEvent(ctx context.Context, evt events.Event) error {
|
||||
trigger := string(evt.Type)
|
||||
rules, err := e.db.GetActiveRules(trigger)
|
||||
if err != nil {
|
||||
e.log.Error("Failed to fetch active rules", zap.String("trigger", trigger), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
|
||||
if len(rules) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Fetch full entry with latest variants if entry ID is present
|
||||
entry := evt.Entry
|
||||
if entry.ID != "" {
|
||||
if full, err := e.db.GetEntry(entry.ID); err == nil && full != nil {
|
||||
entry = *full
|
||||
}
|
||||
}
|
||||
|
||||
for _, r := range rules {
|
||||
if e.evaluator.Evaluate(r, entry) {
|
||||
e.log.Info("Rule matched",
|
||||
zap.String("rule_id", r.ID),
|
||||
zap.String("rule_name", r.Name),
|
||||
zap.String("entry_id", entry.ID),
|
||||
zap.Int("actions_count", len(r.Actions)),
|
||||
)
|
||||
|
||||
for _, action := range r.Actions {
|
||||
if e.dispatcher != nil {
|
||||
e.dispatcher.Dispatch(ctx, action, entry, evt.Depth+1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||
)
|
||||
|
||||
type Evaluator struct{}
|
||||
|
||||
func NewEvaluator() *Evaluator {
|
||||
return &Evaluator{}
|
||||
}
|
||||
|
||||
// Evaluate checks whether a FeedEntry satisfies a Rule's conditions.
|
||||
func (e *Evaluator) Evaluate(rule models.Rule, entry models.FeedEntry) bool {
|
||||
if len(rule.Conditions) == 0 {
|
||||
return true
|
||||
}
|
||||
|
||||
matchMode := strings.ToLower(rule.MatchMode)
|
||||
if matchMode == "" || matchMode == "all" {
|
||||
for _, cond := range rule.Conditions {
|
||||
if !e.evaluateCondition(cond, entry) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// matchMode == "any"
|
||||
for _, cond := range rule.Conditions {
|
||||
if e.evaluateCondition(cond, entry) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (e *Evaluator) evaluateCondition(cond models.RuleCondition, entry models.FeedEntry) bool {
|
||||
actualValue := e.resolveField(cond.Field, entry)
|
||||
return e.compare(actualValue, cond.Operator, cond.Value)
|
||||
}
|
||||
|
||||
func (e *Evaluator) resolveField(field string, entry models.FeedEntry) interface{} {
|
||||
switch strings.ToLower(field) {
|
||||
case "feed_id":
|
||||
return entry.FeedID
|
||||
case "feed_title":
|
||||
return entry.FeedTitle
|
||||
case "title":
|
||||
return entry.Title
|
||||
case "author":
|
||||
return entry.Author
|
||||
case "url":
|
||||
return entry.URL
|
||||
case "content", "rss_content":
|
||||
return entry.Content
|
||||
case "is_read":
|
||||
return entry.IsRead
|
||||
case "is_starred":
|
||||
return entry.IsStarred
|
||||
}
|
||||
|
||||
// Variant field resolution: "variants.keywords", "variants.readability_full_text", "variants.tags"
|
||||
if strings.HasPrefix(field, "variants.") {
|
||||
variantKey := strings.TrimPrefix(field, "variants.")
|
||||
if entry.Variants != nil {
|
||||
if v, ok := entry.Variants[variantKey]; ok {
|
||||
return v.TextValue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (e *Evaluator) compare(actual interface{}, op string, expected interface{}) bool {
|
||||
if actual == nil {
|
||||
return op == "neq"
|
||||
}
|
||||
|
||||
actualStr := fmt.Sprintf("%v", actual)
|
||||
expectedStr := fmt.Sprintf("%v", expected)
|
||||
|
||||
switch strings.ToLower(op) {
|
||||
case "eq", "==":
|
||||
return strings.EqualFold(actualStr, expectedStr)
|
||||
case "neq", "!=":
|
||||
return !strings.EqualFold(actualStr, expectedStr)
|
||||
case "contains":
|
||||
return strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedStr))
|
||||
case "not_contains":
|
||||
return !strings.Contains(strings.ToLower(actualStr), strings.ToLower(expectedStr))
|
||||
case "in":
|
||||
// expected can be []interface{}, []string, or comma-separated string
|
||||
switch v := expected.(type) {
|
||||
case []interface{}:
|
||||
for _, item := range v {
|
||||
if strings.EqualFold(actualStr, fmt.Sprintf("%v", item)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case []string:
|
||||
for _, item := range v {
|
||||
if strings.EqualFold(actualStr, item) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
case string:
|
||||
items := strings.Split(v, ",")
|
||||
for _, item := range items {
|
||||
if strings.EqualFold(strings.TrimSpace(actualStr), strings.TrimSpace(item)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
return false
|
||||
case "matches", "regex":
|
||||
re, err := regexp.Compile(expectedStr)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
return re.MatchString(actualStr)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package rules
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
|
||||
)
|
||||
|
||||
func TestRuleEvaluator(t *testing.T) {
|
||||
eval := NewEvaluator()
|
||||
|
||||
entry := models.FeedEntry{
|
||||
ID: "entry-1",
|
||||
FeedID: "feed-hn",
|
||||
FeedTitle: "Hacker News",
|
||||
Title: "Announcing Go 1.24 Release",
|
||||
Author: "Go Team",
|
||||
Content: "<p>Go 1.24 brings new features...</p>",
|
||||
Variants: map[string]models.EntryVariant{
|
||||
"keywords": {
|
||||
EntryID: "entry-1",
|
||||
VariantKey: "keywords",
|
||||
ContentType: "json",
|
||||
TextValue: `["go", "compiler", "release"]`,
|
||||
},
|
||||
},
|
||||
PublishedAt: time.Now(),
|
||||
}
|
||||
|
||||
// Test 1: Title contains Go AND FeedID == feed-hn
|
||||
rule1 := models.Rule{
|
||||
ID: "r1",
|
||||
Name: "Tech Feeds",
|
||||
MatchMode: "all",
|
||||
Conditions: []models.RuleCondition{
|
||||
{Field: "feed_id", Operator: "eq", Value: "feed-hn"},
|
||||
{Field: "title", Operator: "contains", Value: "Go 1.24"},
|
||||
},
|
||||
}
|
||||
if !eval.Evaluate(rule1, entry) {
|
||||
t.Error("expected rule1 to match entry")
|
||||
}
|
||||
|
||||
// Test 2: Negative condition (author != Python Team)
|
||||
rule2 := models.Rule{
|
||||
ID: "r2",
|
||||
Name: "Author check",
|
||||
MatchMode: "all",
|
||||
Conditions: []models.RuleCondition{
|
||||
{Field: "author", Operator: "neq", Value: "Python Team"},
|
||||
},
|
||||
}
|
||||
if !eval.Evaluate(rule2, entry) {
|
||||
t.Error("expected rule2 to match entry")
|
||||
}
|
||||
|
||||
// Test 3: Variant matching (variants.keywords contains compiler)
|
||||
rule3 := models.Rule{
|
||||
ID: "r3",
|
||||
Name: "Keywords check",
|
||||
MatchMode: "all",
|
||||
Conditions: []models.RuleCondition{
|
||||
{Field: "variants.keywords", Operator: "contains", Value: "compiler"},
|
||||
},
|
||||
}
|
||||
if !eval.Evaluate(rule3, entry) {
|
||||
t.Error("expected rule3 to match entry variant")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user