feat: implement rule engine, worker pool, event bus, and readability scraping with enhanced logging and content sanitization.

This commit is contained in:
2026-08-25 00:31:32 -07:00
parent 5ea176d5ad
commit d440728ca5
29 changed files with 2788 additions and 204 deletions
+82
View File
@@ -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
}
+133
View File
@@ -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
}
}
+70
View File
@@ -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")
}
}