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
+123
View File
@@ -0,0 +1,123 @@
package readability
import (
"context"
"fmt"
"net/http"
"net/url"
"strings"
"time"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
readability "github.com/go-shiori/go-readability"
"github.com/microcosm-cc/bluemonday"
"go.uber.org/zap"
)
type Scraper struct {
client *http.Client
sanitizer *bluemonday.Policy
log *zap.Logger
}
func NewScraper(timeout time.Duration) *Scraper {
if timeout <= 0 {
timeout = 15 * time.Second
}
p := bluemonday.UGCPolicy()
// Allow rich media & responsive elements
p.AllowElements("video", "audio", "source", "track", "iframe", "picture", "figure", "figcaption", "article", "section")
p.AllowAttrs("src", "poster", "controls", "autoplay", "loop", "muted", "preload").OnElements("video", "audio")
p.AllowAttrs("src", "type", "srcset", "media").OnElements("source")
p.AllowAttrs("src", "width", "height", "frameborder", "allow", "allowfullscreen", "sandbox").OnElements("iframe")
p.AllowAttrs("data-src", "data-original", "loading", "srcset", "sizes").OnElements("img")
p.AllowAttrs("class", "id").Globally()
return &Scraper{
client: &http.Client{
Timeout: timeout,
},
sanitizer: p,
log: logger.Named("readability-scraper"),
}
}
// Handler returns the worker.ActionHandler function for readability scraping.
func (s *Scraper) Handler() worker.ActionHandler {
return func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*worker.ActionResult, error) {
if strings.TrimSpace(entry.URL) == "" {
return nil, fmt.Errorf("entry URL is empty")
}
parsedURL, err := url.Parse(entry.URL)
if err != nil {
return nil, fmt.Errorf("invalid URL: %w", err)
}
s.log.Info("Starting readability scrape",
zap.String("entry_id", entry.ID),
zap.String("url", entry.URL),
)
req, err := http.NewRequestWithContext(ctx, "GET", entry.URL, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.0.0 Safari/537.36")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Accept-Language", "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7")
resp, err := s.client.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to fetch article page: %w", err)
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, fmt.Errorf("server returned HTTP status %d", resp.StatusCode)
}
article, err := readability.FromReader(resp.Body, parsedURL)
if err != nil {
return nil, fmt.Errorf("failed to parse readability article: %w", err)
}
cleanHTML := s.sanitizer.Sanitize(article.Content)
if strings.TrimSpace(cleanHTML) == "" && strings.TrimSpace(article.TextContent) != "" {
cleanHTML = "<p>" + s.sanitizer.Sanitize(article.TextContent) + "</p>"
}
s.log.Info("Readability scrape successful",
zap.String("entry_id", entry.ID),
zap.String("title", article.Title),
zap.Int("content_length", len(cleanHTML)),
)
now := time.Now().UTC()
variant := models.EntryVariant{
EntryID: entry.ID,
VariantKey: "readability_full_text",
ContentType: "html",
TextValue: cleanHTML,
Metadata: map[string]interface{}{
"title": article.Title,
"byline": article.Byline,
"excerpt": article.Excerpt,
"length": len(article.TextContent),
"scraped_at": now.Format(time.RFC3339),
},
CreatedAt: now,
UpdatedAt: now,
}
return &worker.ActionResult{
ResultVariant: &variant,
}, nil
}
}
+161 -1
View File
@@ -17,6 +17,7 @@ import (
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/opml"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/parser"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/worker"
"github.com/go-chi/chi/v5"
)
@@ -25,14 +26,16 @@ type Handler struct {
auth *auth.AuthService
crawler *crawler.Crawler
parser *parser.Parser
pool *worker.Pool
}
func NewHandler(database *db.DB, authService *auth.AuthService, cr *crawler.Crawler) *Handler {
func NewHandler(database *db.DB, authService *auth.AuthService, cr *crawler.Crawler, pool *worker.Pool) *Handler {
return &Handler{
db: database,
auth: authService,
crawler: cr,
parser: parser.NewParser(),
pool: pool,
}
}
@@ -547,3 +550,160 @@ func (h *Handler) ViewSharedEntry(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = w.Write([]byte(html))
}
// GetEntry returns a single entry by ID with its loaded variants
func (h *Handler) GetEntry(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
entry, err := h.db.GetEntry(id)
if err != nil {
if err == sql.ErrNoRows {
h.writeError(w, http.StatusNotFound, "entry not found")
return
}
h.writeError(w, http.StatusInternalServerError, err.Error())
return
}
h.writeJSON(w, http.StatusOK, entry)
}
// ListEntryVariants returns all variants for a given entry ID
func (h *Handler) ListEntryVariants(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
variants, err := h.db.ListVariantsForEntry(id)
if err != nil {
h.writeError(w, http.StatusInternalServerError, err.Error())
return
}
h.writeJSON(w, http.StatusOK, variants)
}
// GetVariantBlobStream streams the audio/binary file for a variant with HTTP 206 Partial Content support
func (h *Handler) GetVariantBlobStream(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
key := chi.URLParam(r, "key")
variant, err := h.db.GetVariant(id, key)
if err != nil {
h.writeError(w, http.StatusNotFound, "variant not found")
return
}
if variant.BlobPath == "" {
h.writeError(w, http.StatusNotFound, "variant has no binary media blob")
return
}
http.ServeFile(w, r, variant.BlobPath)
}
// ScrapeEntry manually triggers readability full text scraping and saves to entry_variants
func (h *Handler) ScrapeEntry(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
entry, err := h.db.GetEntry(id)
if err != nil {
h.writeError(w, http.StatusNotFound, "entry not found")
return
}
if h.pool == nil {
h.writeError(w, http.StatusInternalServerError, "worker pool not configured")
return
}
res, err := h.pool.ExecuteDirect(r.Context(), "readability_scrape", *entry, nil)
if err != nil {
h.writeError(w, http.StatusInternalServerError, fmt.Sprintf("scraping failed: %v", err))
return
}
if res != nil && res.ResultVariant != nil {
_ = h.db.SetVariant(*res.ResultVariant)
}
// Reload entry with newly saved variant
updatedEntry, err := h.db.GetEntry(id)
if err != nil {
h.writeError(w, http.StatusInternalServerError, err.Error())
return
}
h.writeJSON(w, http.StatusOK, updatedEntry)
}
// Rules API Handlers
func (h *Handler) ListRules(w http.ResponseWriter, r *http.Request) {
rules, err := h.db.ListRules()
if err != nil {
h.writeError(w, http.StatusInternalServerError, err.Error())
return
}
if rules == nil {
rules = []models.Rule{}
}
h.writeJSON(w, http.StatusOK, rules)
}
type CreateRuleRequest struct {
Name string `json:"name"`
Trigger string `json:"trigger"`
MatchMode string `json:"match_mode"`
Conditions []models.RuleCondition `json:"conditions"`
Actions []models.RuleAction `json:"actions"`
}
func (h *Handler) CreateRule(w http.ResponseWriter, r *http.Request) {
var body CreateRuleRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request body")
return
}
if strings.TrimSpace(body.Name) == "" {
h.writeError(w, http.StatusBadRequest, "rule name is required")
return
}
if strings.TrimSpace(body.Trigger) == "" {
body.Trigger = "on_post_created"
}
if strings.TrimSpace(body.MatchMode) == "" {
body.MatchMode = "all"
}
rule, err := h.db.CreateRule(body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions)
if err != nil {
h.writeError(w, http.StatusInternalServerError, err.Error())
return
}
h.writeJSON(w, http.StatusCreated, rule)
}
type UpdateRuleRequest struct {
Name string `json:"name"`
Trigger string `json:"trigger"`
MatchMode string `json:"match_mode"`
Conditions []models.RuleCondition `json:"conditions"`
Actions []models.RuleAction `json:"actions"`
IsActive bool `json:"is_active"`
}
func (h *Handler) UpdateRule(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
var body UpdateRuleRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
h.writeError(w, http.StatusBadRequest, "invalid request body")
return
}
rule, err := h.db.UpdateRule(id, body.Name, body.Trigger, body.MatchMode, body.Conditions, body.Actions, body.IsActive)
if err != nil {
h.writeError(w, http.StatusInternalServerError, err.Error())
return
}
h.writeJSON(w, http.StatusOK, rule)
}
func (h *Handler) DeleteRule(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
if err := h.db.DeleteRule(id); err != nil {
h.writeError(w, http.StatusInternalServerError, err.Error())
return
}
h.writeJSON(w, http.StatusOK, map[string]string{"message": "rule deleted"})
}
+10
View File
@@ -62,9 +62,19 @@ func NewRouter(h *Handler, authService *auth.AuthService, staticDir string) http
// Entries
r.Get("/entries", h.ListEntries)
r.Get("/entries/{id}", h.GetEntry)
r.Put("/entries/{id}/read", h.UpdateEntryRead)
r.Put("/entries/{id}/star", h.UpdateEntryStar)
r.Post("/entries/mark-all-read", h.MarkAllRead)
r.Post("/entries/{id}/scrape", h.ScrapeEntry)
r.Get("/entries/{id}/variants", h.ListEntryVariants)
r.Get("/entries/{id}/variants/{key}/stream", h.GetVariantBlobStream)
// Rules
r.Get("/rules", h.ListRules)
r.Post("/rules", h.CreateRule)
r.Put("/rules/{id}", h.UpdateRule)
r.Delete("/rules/{id}", h.DeleteRule)
// OPML
r.Post("/opml/import", h.ImportOPML)
+43 -14
View File
@@ -3,35 +3,41 @@ package crawler
import (
"context"
"fmt"
"log"
"sync"
"time"
"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"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/parser"
"go.uber.org/zap"
)
type Crawler struct {
db *db.DB
parser *parser.Parser
bus *events.EventBus
intervalMinutes int
workerCount int
isRefreshing bool
mu sync.Mutex
log *zap.Logger
}
func NewCrawler(database *db.DB, intervalMinutes, workers int) *Crawler {
func NewCrawler(database *db.DB, bus *events.EventBus, intervalMinutes, workers int) *Crawler {
return &Crawler{
db: database,
parser: parser.NewParser(),
bus: bus,
intervalMinutes: intervalMinutes,
workerCount: workers,
log: logger.Named("crawler"),
}
}
func (c *Crawler) Start(ctx context.Context) {
log.Printf("==> Starting RSS background crawler scheduler (Workers: %d)\n", c.workerCount)
c.log.Info("Starting RSS background crawler scheduler", zap.Int("worker_count", c.workerCount))
// Run initial fetch asynchronously after 2 seconds startup
go func() {
@@ -72,7 +78,7 @@ func (c *Crawler) RefreshDue(ctx context.Context) {
feeds, err := c.db.ListFeeds()
if err != nil {
log.Printf("Crawler error listing feeds: %v\n", err)
c.log.Error("Crawler error listing feeds", zap.Error(err))
return
}
@@ -93,7 +99,7 @@ func (c *Crawler) RefreshDue(ctx context.Context) {
return
}
log.Printf("Crawler: Refreshing %d due feeds (out of %d total)...\n", len(dueFeeds), len(feeds))
c.log.Info("Crawler refreshing due feeds", zap.Int("due_count", len(dueFeeds)), zap.Int("total_count", len(feeds)))
c.runWorkerPool(ctx, dueFeeds)
}
@@ -102,7 +108,7 @@ func (c *Crawler) RefreshAll(ctx context.Context) {
c.mu.Lock()
if c.isRefreshing {
c.mu.Unlock()
log.Println("Crawler: RefreshAll already in progress, skipping")
c.log.Debug("Crawler RefreshAll already in progress, skipping")
return
}
c.isRefreshing = true
@@ -116,7 +122,7 @@ func (c *Crawler) RefreshAll(ctx context.Context) {
feeds, err := c.db.ListFeeds()
if err != nil {
log.Printf("Crawler error listing feeds: %v\n", err)
c.log.Error("Crawler error listing feeds", zap.Error(err))
return
}
@@ -124,7 +130,7 @@ func (c *Crawler) RefreshAll(ctx context.Context) {
return
}
log.Printf("Crawler: Refreshing all %d feeds...\n", len(feeds))
c.log.Info("Crawler refreshing all feeds", zap.Int("count", len(feeds)))
c.runWorkerPool(ctx, feeds)
}
@@ -148,7 +154,7 @@ func (c *Crawler) runWorkerPool(ctx context.Context, feeds []models.Feed) {
close(jobs)
wg.Wait()
log.Printf("Crawler: Completed refresh of %d feeds.\n", len(feeds))
c.log.Info("Crawler completed refresh batch", zap.Int("feed_count", len(feeds)))
}
func (c *Crawler) RefreshSingle(ctx context.Context, feedID string) error {
@@ -169,7 +175,11 @@ func (c *Crawler) fetchSingleFeed(ctx context.Context, f models.Feed) error {
res, err := c.parser.FetchFeed(feedCtx, f.FeedURL, f.ETag, f.LastModified)
if err != nil {
log.Printf("Crawler error fetching [%s]: %v\n", f.Title, err)
c.log.Warn("Crawler error fetching feed",
zap.String("feed_title", f.Title),
zap.String("feed_url", f.FeedURL),
zap.Error(err),
)
_ = c.db.UpdateFeedFetchStatus(f.ID, f.ETag, f.LastModified, err.Error())
return err
}
@@ -180,11 +190,30 @@ func (c *Crawler) fetchSingleFeed(ctx context.Context, f models.Feed) error {
}
// Upsert entries
inserted, err := c.db.UpsertFeedEntries(f.ID, res.Entries)
newEntries, err := c.db.UpsertFeedEntries(f.ID, res.Entries)
if err != nil {
log.Printf("Crawler error upserting entries for [%s]: %v\n", f.Title, err)
} else if inserted > 0 {
log.Printf("Crawler [%s]: inserted %d new entries\n", f.Title, inserted)
c.log.Error("Crawler error upserting entries",
zap.String("feed_title", f.Title),
zap.Error(err),
)
} else if len(newEntries) > 0 {
c.log.Info("Crawler inserted new entries",
zap.String("feed_title", f.Title),
zap.Int("new_entries", len(newEntries)),
)
// Publish on_post_created events for newly inserted entries
if c.bus != nil {
for _, entry := range newEntries {
entry.FeedTitle = f.Title
entry.FeedSiteURL = f.SiteURL
c.bus.Publish(ctx, events.Event{
Type: events.EventPostCreated,
Entry: entry,
Depth: 0,
})
}
}
}
_ = c.db.UpdateFeedFetchStatus(f.ID, res.ETag, res.LastModified, "")
+390 -83
View File
@@ -114,15 +114,28 @@ func (d *DB) migrate() error {
CREATE INDEX IF NOT EXISTS idx_entries_is_starred ON feed_entries(is_starred);
CREATE INDEX IF NOT EXISTS idx_entries_share_uuid ON feed_entries(share_uuid);
CREATE TABLE IF NOT EXISTS entry_variants (
entry_id TEXT NOT NULL,
variant_key TEXT NOT NULL,
content_type TEXT NOT NULL,
text_value TEXT,
blob_path TEXT,
metadata TEXT DEFAULT '{}',
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL,
PRIMARY KEY (entry_id, variant_key),
FOREIGN KEY(entry_id) REFERENCES feed_entries(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_entry_variants_key ON entry_variants(variant_key);
CREATE TABLE IF NOT EXISTS rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
rule_type TEXT NOT NULL,
is_active BOOLEAN DEFAULT 1,
trigger_type TEXT NOT NULL,
trigger_config TEXT DEFAULT '{}',
trigger TEXT NOT NULL,
conditions TEXT DEFAULT '[]',
match_mode TEXT DEFAULT 'all',
actions TEXT DEFAULT '[]',
is_active BOOLEAN DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
@@ -132,6 +145,26 @@ func (d *DB) migrate() error {
}
// Migration for timeout_seconds column if older DB exists
_, _ = d.Exec("ALTER TABLE feeds ADD COLUMN timeout_seconds INTEGER DEFAULT 10")
// Migration for rules table schema if older table exists
var testCol string
err := d.QueryRow("SELECT trigger FROM rules LIMIT 1").Scan(&testCol)
if err != nil && err != sql.ErrNoRows {
_, _ = d.Exec("DROP TABLE IF EXISTS rules")
_, _ = d.Exec(`
CREATE TABLE IF NOT EXISTS rules (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
trigger TEXT NOT NULL,
conditions TEXT DEFAULT '[]',
match_mode TEXT DEFAULT 'all',
actions TEXT DEFAULT '[]',
is_active BOOLEAN DEFAULT 1,
created_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL
);
`)
}
return nil
}
@@ -474,51 +507,67 @@ func (d *DB) DeleteFeed(id string) error {
}
// Feed Entries operations
func (d *DB) UpsertFeedEntries(feedID string, entries []models.FeedEntry) (int, error) {
func (d *DB) UpsertFeedEntries(feedID string, entries []models.FeedEntry) ([]models.FeedEntry, error) {
if len(entries) == 0 {
return 0, nil
return nil, nil
}
stmt, err := d.Prepare(`
var newEntries []models.FeedEntry
now := time.Now().UTC()
insertStmt, err := d.Prepare(`
INSERT INTO feed_entries (
id, feed_id, guid, url, title, author, content, raw_content, published_at, is_read, is_starred, metadata, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, 0, ?, ?)
ON CONFLICT(feed_id, guid) DO UPDATE SET
title = excluded.title,
content = CASE WHEN feed_entries.content = '' THEN excluded.content ELSE feed_entries.content END,
url = excluded.url,
author = excluded.author
`)
if err != nil {
return 0, err
return nil, err
}
defer func() {
_ = stmt.Close()
_ = insertStmt.Close()
}()
insertedCount := 0
for _, e := range entries {
id := uuid.New().String()
now := time.Now().UTC()
pubAt := e.PublishedAt
if pubAt.IsZero() {
pubAt = now
}
metaBytes, _ := json.Marshal(e.Metadata)
if len(metaBytes) == 0 {
metaBytes = []byte("{}")
}
updateStmt, err := d.Prepare(`
UPDATE feed_entries
SET title = ?, url = ?, author = ?, content = ?, raw_content = ?
WHERE feed_id = ? AND guid = ?
`)
if err != nil {
return nil, err
}
defer func() {
_ = updateStmt.Close()
}()
res, err := stmt.Exec(
id, feedID, e.GUID, e.URL, e.Title, e.Author, e.Content, e.RawContent, pubAt, string(metaBytes), now,
)
if err == nil {
if n, _ := res.RowsAffected(); n > 0 {
insertedCount++
for _, e := range entries {
var existingID string
err := d.QueryRow("SELECT id FROM feed_entries WHERE feed_id = ? AND guid = ?", feedID, e.GUID).Scan(&existingID)
switch err {
case sql.ErrNoRows:
e.ID = uuid.New().String()
e.FeedID = feedID
pubAt := e.PublishedAt
if pubAt.IsZero() {
pubAt = now
}
metaBytes, _ := json.Marshal(e.Metadata)
if len(metaBytes) == 0 {
metaBytes = []byte("{}")
}
_, err := insertStmt.Exec(
e.ID, feedID, e.GUID, e.URL, e.Title, e.Author, e.Content, e.RawContent, pubAt, string(metaBytes), now,
)
if err == nil {
e.CreatedAt = now
newEntries = append(newEntries, e)
}
case nil:
_, _ = updateStmt.Exec(e.Title, e.URL, e.Author, e.Content, e.RawContent, feedID, e.GUID)
}
}
return insertedCount, nil
return newEntries, nil
}
type EntryFilter struct {
@@ -530,67 +579,98 @@ type EntryFilter struct {
Offset int
}
func (d *DB) ListEntries(f EntryFilter) ([]models.FeedEntry, int, error) {
where := []string{"1=1"}
args := []interface{}{}
func (d *DB) GetEntry(id string) (*models.FeedEntry, error) {
query := `
SELECT
e.id, e.feed_id, f.title as feed_title, COALESCE(f.site_url, '') as feed_site_url,
e.guid, e.url, e.title, e.author, e.content, e.raw_content,
e.published_at, e.is_read, e.is_starred, e.share_uuid, e.metadata, e.created_at
FROM feed_entries e
JOIN feeds f ON f.id = e.feed_id
WHERE e.id = ?
`
var e models.FeedEntry
var author, rawContent, shareUUID, metaStr sql.NullString
err := d.QueryRow(query, id).Scan(
&e.ID, &e.FeedID, &e.FeedTitle, &e.FeedSiteURL,
&e.GUID, &e.URL, &e.Title, &author, &e.Content, &rawContent,
&e.PublishedAt, &e.IsRead, &e.IsStarred, &shareUUID, &metaStr, &e.CreatedAt,
)
if err != nil {
return nil, err
}
if author.Valid {
e.Author = author.String
}
if rawContent.Valid {
e.RawContent = rawContent.String
}
if shareUUID.Valid {
s := shareUUID.String
e.ShareUUID = &s
}
e.Metadata = make(map[string]interface{})
if metaStr.Valid && metaStr.String != "" {
_ = json.Unmarshal([]byte(metaStr.String), &e.Metadata)
}
switch f.View {
// Load variants
variants, _ := d.ListVariantsForEntry(e.ID)
e.Variants = variants
return &e, nil
}
func (d *DB) ListEntries(filter EntryFilter) ([]models.FeedEntry, int, error) {
var whereClauses []string
var args []interface{}
switch filter.View {
case "inbox":
where = append(where, "e.is_read = 0")
whereClauses = append(whereClauses, "e.is_read = 0")
case "starred":
where = append(where, "e.is_starred = 1")
whereClauses = append(whereClauses, "e.is_starred = 1")
}
if f.FeedID != "" {
where = append(where, "e.feed_id = ?")
args = append(args, f.FeedID)
if filter.FeedID != "" {
whereClauses = append(whereClauses, "e.feed_id = ?")
args = append(args, filter.FeedID)
}
if f.CategoryID != "" {
where = append(where, "f.category_id = ?")
args = append(args, f.CategoryID)
if filter.CategoryID != "" {
whereClauses = append(whereClauses, "f.category_id = ?")
args = append(args, filter.CategoryID)
}
if f.Search != "" {
searchTerms := strings.Fields(f.Search)
for _, term := range searchTerms {
lower := strings.ToLower(term)
if strings.HasPrefix(lower, "is:unread") {
where = append(where, "e.is_read = 0")
} else if strings.HasPrefix(lower, "is:read") {
where = append(where, "e.is_read = 1")
} else if strings.HasPrefix(lower, "is:starred") {
where = append(where, "e.is_starred = 1")
} else if strings.HasPrefix(lower, "feed:") {
feedVal := strings.TrimPrefix(term, "feed:")
where = append(where, "(f.title LIKE ? OR f.feed_url LIKE ?)")
args = append(args, "%"+feedVal+"%", "%"+feedVal+"%")
} else {
where = append(where, "(e.title LIKE ? OR e.content LIKE ? OR e.author LIKE ?)")
args = append(args, "%"+term+"%", "%"+term+"%", "%"+term+"%")
}
}
if filter.Search != "" {
whereClauses = append(whereClauses, "(e.title LIKE ? OR e.content LIKE ? OR e.author LIKE ?)")
pattern := "%" + filter.Search + "%"
args = append(args, pattern, pattern, pattern)
}
whereClause := strings.Join(where, " AND ")
whereSQL := ""
if len(whereClauses) > 0 {
whereSQL = "WHERE " + strings.Join(whereClauses, " AND ")
}
// Count total
countQuery := fmt.Sprintf(`
SELECT COUNT(*)
FROM feed_entries e
JOIN feeds f ON f.id = e.feed_id
WHERE %s
`, whereClause)
SELECT COUNT(e.id)
FROM feed_entries e
JOIN feeds f ON f.id = e.feed_id
%s
`, whereSQL)
var total int
if err := d.QueryRow(countQuery, args...).Scan(&total); err != nil {
err := d.QueryRow(countQuery, args...).Scan(&total)
if err != nil {
return nil, 0, err
}
limit := f.Limit
if limit <= 0 || limit > 100 {
limit = 50
limit := filter.Limit
if limit <= 0 || limit > 500 {
limit = 100
}
offset := f.Offset
offset := filter.Offset
if offset < 0 {
offset = 0
}
@@ -602,10 +682,10 @@ func (d *DB) ListEntries(f EntryFilter) ([]models.FeedEntry, int, error) {
e.published_at, e.is_read, e.is_starred, e.share_uuid, e.metadata, e.created_at
FROM feed_entries e
JOIN feeds f ON f.id = e.feed_id
WHERE %s
ORDER BY e.published_at DESC
%s
ORDER BY e.published_at DESC, e.created_at DESC
LIMIT ? OFFSET ?
`, whereClause)
`, whereSQL)
queryArgs := append(args, limit, offset)
rows, err := d.Query(query, queryArgs...)
@@ -619,13 +699,13 @@ func (d *DB) ListEntries(f EntryFilter) ([]models.FeedEntry, int, error) {
var entries []models.FeedEntry
for rows.Next() {
var e models.FeedEntry
var author, rawContent, metaStr sql.NullString
var shareUUID sql.NullString
if err := rows.Scan(
var author, rawContent, shareUUID, metaStr sql.NullString
err := rows.Scan(
&e.ID, &e.FeedID, &e.FeedTitle, &e.FeedSiteURL,
&e.GUID, &e.URL, &e.Title, &author, &e.Content, &rawContent,
&e.PublishedAt, &e.IsRead, &e.IsStarred, &shareUUID, &metaStr, &e.CreatedAt,
); err != nil {
)
if err != nil {
return nil, 0, err
}
if author.Valid {
@@ -675,6 +755,232 @@ func (d *DB) MarkAllRead(feedID, categoryID *string) error {
return err
}
// Entry Variants CRUD
func (d *DB) GetVariant(entryID, key string) (*models.EntryVariant, error) {
query := `
SELECT entry_id, variant_key, content_type, COALESCE(text_value, ''), COALESCE(blob_path, ''), metadata, created_at, updated_at
FROM entry_variants
WHERE entry_id = ? AND variant_key = ?
`
var v models.EntryVariant
var metaStr sql.NullString
err := d.QueryRow(query, entryID, key).Scan(
&v.EntryID, &v.VariantKey, &v.ContentType, &v.TextValue, &v.BlobPath, &metaStr, &v.CreatedAt, &v.UpdatedAt,
)
if err != nil {
return nil, err
}
v.Metadata = make(map[string]interface{})
if metaStr.Valid && metaStr.String != "" {
_ = json.Unmarshal([]byte(metaStr.String), &v.Metadata)
}
return &v, nil
}
func (d *DB) ListVariantsForEntry(entryID string) (map[string]models.EntryVariant, error) {
query := `
SELECT entry_id, variant_key, content_type, COALESCE(text_value, ''), COALESCE(blob_path, ''), metadata, created_at, updated_at
FROM entry_variants
WHERE entry_id = ?
`
rows, err := d.Query(query, entryID)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
variants := make(map[string]models.EntryVariant)
for rows.Next() {
var v models.EntryVariant
var metaStr sql.NullString
err := rows.Scan(
&v.EntryID, &v.VariantKey, &v.ContentType, &v.TextValue, &v.BlobPath, &metaStr, &v.CreatedAt, &v.UpdatedAt,
)
if err != nil {
return nil, err
}
v.Metadata = make(map[string]interface{})
if metaStr.Valid && metaStr.String != "" {
_ = json.Unmarshal([]byte(metaStr.String), &v.Metadata)
}
variants[v.VariantKey] = v
}
return variants, nil
}
func (d *DB) SetVariant(v models.EntryVariant) error {
now := time.Now().UTC()
metaBytes, _ := json.Marshal(v.Metadata)
if len(metaBytes) == 0 {
metaBytes = []byte("{}")
}
query := `
INSERT INTO entry_variants (
entry_id, variant_key, content_type, text_value, blob_path, metadata, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(entry_id, variant_key) DO UPDATE SET
content_type = excluded.content_type,
text_value = excluded.text_value,
blob_path = excluded.blob_path,
metadata = excluded.metadata,
updated_at = excluded.updated_at
`
_, err := d.Exec(query, v.EntryID, v.VariantKey, v.ContentType, v.TextValue, v.BlobPath, string(metaBytes), now, now)
return err
}
func (d *DB) DeleteVariant(entryID, key string) error {
_, err := d.Exec("DELETE FROM entry_variants WHERE entry_id = ? AND variant_key = ?", entryID, key)
return err
}
// Rules CRUD
func (d *DB) ListRules() ([]models.Rule, error) {
query := `
SELECT id, name, trigger, conditions, match_mode, actions, is_active, created_at, updated_at
FROM rules
ORDER BY created_at ASC
`
rows, err := d.Query(query)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var rules []models.Rule
for rows.Next() {
var r models.Rule
var condsStr, actionsStr string
err := rows.Scan(
&r.ID, &r.Name, &r.Trigger, &condsStr, &r.MatchMode, &actionsStr, &r.IsActive, &r.CreatedAt, &r.UpdatedAt,
)
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(condsStr), &r.Conditions)
_ = json.Unmarshal([]byte(actionsStr), &r.Actions)
rules = append(rules, r)
}
return rules, nil
}
func (d *DB) GetActiveRules(trigger string) ([]models.Rule, error) {
query := `
SELECT id, name, trigger, conditions, match_mode, actions, is_active, created_at, updated_at
FROM rules
WHERE is_active = 1 AND (trigger = ? OR trigger = '*')
ORDER BY created_at ASC
`
rows, err := d.Query(query, trigger)
if err != nil {
return nil, err
}
defer func() {
_ = rows.Close()
}()
var rules []models.Rule
for rows.Next() {
var r models.Rule
var condsStr, actionsStr string
err := rows.Scan(
&r.ID, &r.Name, &r.Trigger, &condsStr, &r.MatchMode, &actionsStr, &r.IsActive, &r.CreatedAt, &r.UpdatedAt,
)
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(condsStr), &r.Conditions)
_ = json.Unmarshal([]byte(actionsStr), &r.Actions)
rules = append(rules, r)
}
return rules, nil
}
func (d *DB) GetRule(id string) (*models.Rule, error) {
query := `
SELECT id, name, trigger, conditions, match_mode, actions, is_active, created_at, updated_at
FROM rules
WHERE id = ?
`
var r models.Rule
var condsStr, actionsStr string
err := d.QueryRow(query, id).Scan(
&r.ID, &r.Name, &r.Trigger, &condsStr, &r.MatchMode, &actionsStr, &r.IsActive, &r.CreatedAt, &r.UpdatedAt,
)
if err != nil {
return nil, err
}
_ = json.Unmarshal([]byte(condsStr), &r.Conditions)
_ = json.Unmarshal([]byte(actionsStr), &r.Actions)
return &r, nil
}
func (d *DB) CreateRule(name, trigger, matchMode string, conditions []models.RuleCondition, actions []models.RuleAction) (*models.Rule, error) {
id := uuid.New().String()
now := time.Now().UTC()
if matchMode == "" {
matchMode = "all"
}
condsBytes, _ := json.Marshal(conditions)
actionsBytes, _ := json.Marshal(actions)
query := `
INSERT INTO rules (
id, name, trigger, conditions, match_mode, actions, is_active, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, 1, ?, ?)
`
_, err := d.Exec(query, id, name, trigger, string(condsBytes), matchMode, string(actionsBytes), now, now)
if err != nil {
return nil, err
}
return &models.Rule{
ID: id,
Name: name,
Trigger: trigger,
Conditions: conditions,
MatchMode: matchMode,
Actions: actions,
IsActive: true,
CreatedAt: now,
UpdatedAt: now,
}, nil
}
func (d *DB) UpdateRule(id string, name, trigger, matchMode string, conditions []models.RuleCondition, actions []models.RuleAction, isActive bool) (*models.Rule, error) {
now := time.Now().UTC()
if matchMode == "" {
matchMode = "all"
}
condsBytes, _ := json.Marshal(conditions)
actionsBytes, _ := json.Marshal(actions)
query := `
UPDATE rules
SET name = ?, trigger = ?, conditions = ?, match_mode = ?, actions = ?, is_active = ?, updated_at = ?
WHERE id = ?
`
res, err := d.Exec(query, name, trigger, string(condsBytes), matchMode, string(actionsBytes), isActive, now, id)
if err != nil {
return nil, err
}
if n, _ := res.RowsAffected(); n == 0 {
return nil, fmt.Errorf("rule not found")
}
return d.GetRule(id)
}
func (d *DB) DeleteRule(id string) error {
_, err := d.Exec("DELETE FROM rules WHERE id = ?", id)
return err
}
func (d *DB) GetStats() (*models.Stats, error) {
var s models.Stats
err := d.QueryRow("SELECT COUNT(*) FROM feeds").Scan(&s.TotalFeeds)
@@ -693,5 +999,6 @@ func (d *DB) GetStats() (*models.Stats, error) {
if err != nil {
return nil, err
}
_ = d.QueryRow("SELECT COUNT(*) FROM rules").Scan(&s.TotalRules)
return &s, nil
}
+61 -4
View File
@@ -72,8 +72,8 @@ func TestDatabaseOperations(t *testing.T) {
if err != nil {
t.Fatalf("UpsertFeedEntries failed: %v", err)
}
if inserted != 2 {
t.Fatalf("expected 2 inserted entries, got %d", inserted)
if len(inserted) != 2 {
t.Fatalf("expected 2 inserted entries, got %d", len(inserted))
}
// 5. List entries
@@ -94,12 +94,69 @@ func TestDatabaseOperations(t *testing.T) {
t.Fatalf("UpdateEntryStarred failed: %v", err)
}
// 7. Verify Stats
// 7. Test Variant CRUD
variant := models.EntryVariant{
EntryID: firstID,
VariantKey: "readability_full_text",
ContentType: "html",
TextValue: "<div>Clean full text content</div>",
Metadata: map[string]interface{}{
"scraped_at": time.Now().UTC().Format(time.RFC3339),
"length": 100,
},
}
if err := database.SetVariant(variant); err != nil {
t.Fatalf("SetVariant failed: %v", err)
}
fetchedVariant, err := database.GetVariant(firstID, "readability_full_text")
if err != nil {
t.Fatalf("GetVariant failed: %v", err)
}
if fetchedVariant.TextValue != "<div>Clean full text content</div>" {
t.Fatalf("expected variant text, got %s", fetchedVariant.TextValue)
}
// Test GetEntry with variants
entryWithVariants, err := database.GetEntry(firstID)
if err != nil {
t.Fatalf("GetEntry failed: %v", err)
}
if len(entryWithVariants.Variants) != 1 {
t.Fatalf("expected 1 variant in GetEntry, got %d", len(entryWithVariants.Variants))
}
// 8. Test Rules CRUD
rule, err := database.CreateRule(
"Auto Scrape Rule",
"on_post_created",
"all",
[]models.RuleCondition{
{Field: "feed_id", Operator: "eq", Value: feed.ID},
},
[]models.RuleAction{
{Type: "readability_scrape"},
},
)
if err != nil {
t.Fatalf("CreateRule failed: %v", err)
}
if rule.Name != "Auto Scrape Rule" {
t.Errorf("expected rule name Auto Scrape Rule, got %s", rule.Name)
}
activeRules, err := database.GetActiveRules("on_post_created")
if err != nil {
t.Fatalf("GetActiveRules failed: %v", err)
}
if len(activeRules) != 1 {
t.Fatalf("expected 1 active rule, got %d", len(activeRules))
}
// 9. Verify Stats
stats, err := database.GetStats()
if err != nil {
t.Fatalf("GetStats failed: %v", err)
}
if stats.TotalFeeds != 1 || stats.TotalUnread != 1 || stats.TotalStarred != 1 || stats.TotalCategories != 1 {
if stats.TotalFeeds != 1 || stats.TotalUnread != 1 || stats.TotalStarred != 1 || stats.TotalRules != 1 {
t.Errorf("unexpected stats: %+v", stats)
}
+92
View File
@@ -0,0 +1,92 @@
package events
import (
"context"
"sync"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/logger"
"git.pengzhan.dev/haopengzhan/rsspipe/internal/models"
"go.uber.org/zap"
)
type EventType string
const (
EventPostCreated EventType = "on_post_created"
EventPostUpdated EventType = "on_post_updated"
EventVariantUpdated EventType = "on_variant_updated"
EventManualTrigger EventType = "manual"
)
type Event struct {
Type EventType
Entry models.FeedEntry
VariantKey string // populated when Type == EventVariantUpdated
Depth int
DirectAttrs map[string]interface{}
}
type EventHandler func(ctx context.Context, evt Event) error
type EventBus struct {
mu sync.RWMutex
subscribers map[EventType][]EventHandler
log *zap.Logger
maxDepth int
}
func NewEventBus() *EventBus {
return &EventBus{
subscribers: make(map[EventType][]EventHandler),
log: logger.Named("eventbus"),
maxDepth: 5,
}
}
func (b *EventBus) Subscribe(eventType EventType, handler EventHandler) {
b.mu.Lock()
defer b.mu.Unlock()
b.subscribers[eventType] = append(b.subscribers[eventType], handler)
}
func (b *EventBus) Publish(ctx context.Context, evt Event) {
if evt.Depth > b.maxDepth {
b.log.Warn("Event dropped: recursion depth limit exceeded",
zap.String("type", string(evt.Type)),
zap.String("entry_id", evt.Entry.ID),
zap.Int("depth", evt.Depth),
)
return
}
b.mu.RLock()
handlers := append([]EventHandler{}, b.subscribers[evt.Type]...)
wildcardHandlers := append([]EventHandler{}, b.subscribers["*"]...)
b.mu.RUnlock()
b.log.Debug("Event published",
zap.String("type", string(evt.Type)),
zap.String("entry_id", evt.Entry.ID),
zap.String("variant_key", evt.VariantKey),
zap.Int("depth", evt.Depth),
zap.Int("subscriber_count", len(handlers)+len(wildcardHandlers)),
)
allHandlers := append(handlers, wildcardHandlers...)
for _, h := range allHandlers {
go func(fn EventHandler) {
defer func() {
if r := recover(); r != nil {
b.log.Error("Event handler panic recovered", zap.Any("panic", r))
}
}()
if err := fn(ctx, evt); err != nil {
b.log.Error("Event handler error",
zap.String("type", string(evt.Type)),
zap.String("entry_id", evt.Entry.ID),
zap.Error(err),
)
}
}(h)
}
}
+74
View File
@@ -0,0 +1,74 @@
package logger
import (
"os"
"strings"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
var globalLogger *zap.Logger
var globalSugared *zap.SugaredLogger
func init() {
InitLogger(os.Getenv("LOG_LEVEL"))
}
// InitLogger initializes the global Zap logger with proper formatting and log levels.
func InitLogger(level string) *zap.Logger {
zapLevel := zapcore.InfoLevel
switch strings.ToLower(level) {
case "debug":
zapLevel = zapcore.DebugLevel
case "warn", "warning":
zapLevel = zapcore.WarnLevel
case "error":
zapLevel = zapcore.ErrorLevel
}
encoderConfig := zapcore.EncoderConfig{
TimeKey: "time",
LevelKey: "level",
NameKey: "logger",
CallerKey: "caller",
MessageKey: "msg",
StacktraceKey: "stacktrace",
LineEnding: zapcore.DefaultLineEnding,
EncodeLevel: zapcore.CapitalColorLevelEncoder,
EncodeTime: zapcore.TimeEncoderOfLayout("15:04:05.000"),
EncodeDuration: zapcore.StringDurationEncoder,
EncodeCaller: zapcore.ShortCallerEncoder,
}
core := zapcore.NewCore(
zapcore.NewConsoleEncoder(encoderConfig),
zapcore.AddSync(os.Stdout),
zapLevel,
)
globalLogger = zap.New(core, zap.AddCaller())
globalSugared = globalLogger.Sugar()
return globalLogger
}
// L returns the global zap logger instance.
func L() *zap.Logger {
if globalLogger == nil {
InitLogger("info")
}
return globalLogger
}
// S returns the global sugared logger instance.
func S() *zap.SugaredLogger {
if globalSugared == nil {
InitLogger("info")
}
return globalSugared
}
// Named creates a child logger with a component name tag.
func Named(name string) *zap.Logger {
return L().Named(name)
}
+15
View File
@@ -0,0 +1,15 @@
package logger
import (
"testing"
)
func TestLoggerInit(t *testing.T) {
log := InitLogger("debug")
if log == nil {
t.Fatal("expected non-nil logger")
}
L().Info("Test structured logging message")
S().Infof("Test sugared logging message with %s", "parameter")
Named("test-component").Debug("Test component debug log")
}
+56 -15
View File
@@ -45,22 +45,62 @@ type Feed struct {
// FeedEntry represents an individual article or digest entry
type FeedEntry struct {
ID string `json:"id"`
FeedID string `json:"feed_id"`
FeedTitle string `json:"feed_title"`
FeedSiteURL string `json:"feed_site_url,omitempty"`
GUID string `json:"guid"`
URL string `json:"url"`
Title string `json:"title"`
Author string `json:"author,omitempty"`
Content string `json:"content"`
RawContent string `json:"raw_content,omitempty"`
PublishedAt time.Time `json:"published_at"`
IsRead bool `json:"is_read"`
IsStarred bool `json:"is_starred"`
ShareUUID *string `json:"share_uuid,omitempty"`
Metadata map[string]interface{} `json:"metadata"`
ID string `json:"id"`
FeedID string `json:"feed_id"`
FeedTitle string `json:"feed_title"`
FeedSiteURL string `json:"feed_site_url,omitempty"`
GUID string `json:"guid"`
URL string `json:"url"`
Title string `json:"title"`
Author string `json:"author,omitempty"`
Content string `json:"content"` // Immutable original RSS content
RawContent string `json:"raw_content,omitempty"`
PublishedAt time.Time `json:"published_at"`
IsRead bool `json:"is_read"`
IsStarred bool `json:"is_starred"`
ShareUUID *string `json:"share_uuid,omitempty"`
Variants map[string]EntryVariant `json:"variants,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// EntryVariant represents an extensible enrichment result (readability, keywords, ai_summary, tts)
type EntryVariant struct {
EntryID string `json:"entry_id"`
VariantKey string `json:"variant_key"`
ContentType string `json:"content_type"` // 'text', 'html', 'json', 'yaml', 'audio/mp3', 'binary'
TextValue string `json:"text_value,omitempty"`
BlobPath string `json:"blob_path,omitempty"`
StreamURL string `json:"stream_url,omitempty"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// Rule represents a filter/automation hook rule
type Rule struct {
ID string `json:"id"`
Name string `json:"name"`
Trigger string `json:"trigger"` // 'on_post_created', 'on_post_updated', 'on_variant_updated', 'manual'
Conditions []RuleCondition `json:"conditions"`
MatchMode string `json:"match_mode"` // 'all' (AND) or 'any' (OR)
Actions []RuleAction `json:"actions"`
IsActive bool `json:"is_active"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// RuleCondition represents a single condition predicate
type RuleCondition struct {
Field string `json:"field"` // 'feed_id', 'title', 'author', 'variants.keywords', 'variants.readability_full_text'
Operator string `json:"operator"` // 'eq', 'neq', 'in', 'contains', 'matches'
Value interface{} `json:"value"`
}
// RuleAction represents an action triggered by a rule
type RuleAction struct {
Type string `json:"type"` // 'readability_scrape', 'mark_read', 'mark_starred', 'extract_keywords', 'ai_summary', 'tts'
Params map[string]interface{} `json:"params,omitempty"`
}
// Stats represents dashboard aggregate counts
@@ -69,6 +109,7 @@ type Stats struct {
TotalUnread int `json:"total_unread"`
TotalStarred int `json:"total_starred"`
TotalCategories int `json:"total_categories"`
TotalRules int `json:"total_rules"`
}
// LoginRequest is the body for POST /api/auth/login
+97 -5
View File
@@ -2,7 +2,9 @@ package parser
import (
"context"
"encoding/json"
"fmt"
"html"
"io"
"net/http"
"strings"
@@ -104,10 +106,7 @@ func (p *Parser) FetchFeed(ctx context.Context, feedURL, etag, lastModified stri
pubDate = item.UpdatedParsed.UTC()
}
content := item.Content
if strings.TrimSpace(content) == "" {
content = item.Description
}
content := sanitizeAndCombineContent(item.Content, item.Description)
author := ""
if item.Author != nil {
@@ -120,7 +119,7 @@ func (p *Parser) FetchFeed(ctx context.Context, feedURL, etag, lastModified stri
Title: item.Title,
Author: author,
Content: content,
RawContent: item.Description,
RawContent: content,
PublishedAt: pubDate,
Metadata: make(map[string]interface{}),
}
@@ -129,3 +128,96 @@ func (p *Parser) FetchFeed(ctx context.Context, feedURL, etag, lastModified stri
return result, nil
}
type draftBlock struct {
Key string `json:"key"`
Text string `json:"text"`
Type string `json:"type"`
}
type draftEntityData struct {
URL string `json:"url"`
Src string `json:"src"`
Caption string `json:"caption"`
}
type draftEntity struct {
Type string `json:"type"`
Data draftEntityData `json:"data"`
}
type draftPayload struct {
Blocks []draftBlock `json:"blocks"`
EntityMap map[string]draftEntity `json:"entityMap"`
}
func parseDraftJSText(raw string) (string, bool) {
unescaped := html.UnescapeString(raw)
idx := strings.Index(unescaped, `{"entityMap"`)
if idx == -1 {
idx = strings.Index(unescaped, `{"blocks"`)
}
if idx == -1 {
return "", false
}
prefix := strings.TrimSpace(unescaped[:idx])
jsonPart := strings.TrimSpace(unescaped[idx:])
var payload draftPayload
if err := json.Unmarshal([]byte(jsonPart), &payload); err != nil {
return "", false
}
if len(payload.Blocks) == 0 && len(payload.EntityMap) == 0 {
return "", false
}
var sb strings.Builder
if prefix != "" {
lines := strings.Split(prefix, "\n")
for _, line := range lines {
l := strings.TrimSpace(line)
if l != "" {
sb.WriteString("<p>")
sb.WriteString(html.EscapeString(l))
sb.WriteString("</p>\n")
}
}
}
for _, block := range payload.Blocks {
txt := strings.TrimSpace(block.Text)
if txt != "" {
sb.WriteString("<p>")
sb.WriteString(html.EscapeString(txt))
sb.WriteString("</p>\n")
}
}
for _, ent := range payload.EntityMap {
if strings.EqualFold(ent.Type, "IMAGE") && ent.Data.Src != "" {
fmt.Fprintf(&sb, `<p><img src="%s" alt="%s" /></p>`+"\n", html.EscapeString(ent.Data.Src), html.EscapeString(ent.Data.Caption))
}
}
return sb.String(), true
}
func sanitizeAndCombineContent(content, description string) string {
if parsedDesc, ok := parseDraftJSText(description); ok {
if strings.TrimSpace(content) != "" {
return content + "<br/>\n" + parsedDesc
}
return parsedDesc
}
if parsedContent, ok := parseDraftJSText(content); ok {
return parsedContent
}
if strings.TrimSpace(content) != "" {
return content
}
return description
}
+27
View File
@@ -0,0 +1,27 @@
package parser
import (
"strings"
"testing"
)
func TestParseDraftJSText(t *testing.T) {
desc := `就这样吧评论: 奥德赛 (https://movie.douban.com/subject/36808876/)
评价: 推荐
{"entityMap": {}, "blocks": [{"key": "1", "text": "终于看完了奥德赛,非常精彩!", "type": "unstyled"}]}`
content := `<img src="https://img9.doubanio.com/poster.jpg" /><a href="#">就这样吧</a>评论: <a href="#">奥德赛</a><br/>评价: 推荐`
combined := sanitizeAndCombineContent(content, desc)
if !strings.Contains(combined, "终于看完了奥德赛,非常精彩!") {
t.Fatalf("expected parsed text in output, got: %s", combined)
}
if !strings.Contains(combined, "<p>终于看完了奥德赛,非常精彩!</p>") {
t.Fatalf("expected formatted paragraph in output, got: %s", combined)
}
if !strings.Contains(combined, "img9.doubanio.com/poster.jpg") {
t.Fatalf("expected poster in output, got: %s", combined)
}
}
+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")
}
}
+179
View File
@@ -0,0 +1,179 @@
package worker
import (
"context"
"fmt"
"sync"
"time"
"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 ActionResult struct {
ResultVariant *models.EntryVariant
MarkRead *bool
MarkStarred *bool
}
type ActionHandler func(ctx context.Context, entry models.FeedEntry, params map[string]interface{}) (*ActionResult, error)
type Task struct {
Action models.RuleAction
Entry models.FeedEntry
Depth int
}
type Pool struct {
db *db.DB
bus *events.EventBus
handlers map[string]ActionHandler
queue chan Task
workers int
wg sync.WaitGroup
log *zap.Logger
mu sync.RWMutex
}
func NewPool(database *db.DB, bus *events.EventBus, workers, queueSize int) *Pool {
if workers <= 0 {
workers = 5
}
if queueSize <= 0 {
queueSize = 100
}
return &Pool{
db: database,
bus: bus,
handlers: make(map[string]ActionHandler),
queue: make(chan Task, queueSize),
workers: workers,
log: logger.Named("worker-pool"),
}
}
func (p *Pool) RegisterHandler(actionType string, handler ActionHandler) {
p.mu.Lock()
defer p.mu.Unlock()
p.handlers[actionType] = handler
p.log.Info("Registered action handler", zap.String("action", actionType))
}
func (p *Pool) Start(ctx context.Context) {
p.log.Info("Starting worker pool", zap.Int("worker_count", p.workers))
for i := 0; i < p.workers; i++ {
p.wg.Add(1)
go p.worker(ctx, i)
}
}
func (p *Pool) Stop() {
close(p.queue)
p.wg.Wait()
p.log.Info("Worker pool stopped")
}
func (p *Pool) Dispatch(ctx context.Context, action models.RuleAction, entry models.FeedEntry, depth int) {
select {
case p.queue <- Task{Action: action, Entry: entry, Depth: depth}:
default:
p.log.Warn("Worker pool queue full, task dropped",
zap.String("action", action.Type),
zap.String("entry_id", entry.ID),
)
}
}
func (p *Pool) ExecuteDirect(ctx context.Context, actionType string, entry models.FeedEntry, params map[string]interface{}) (*ActionResult, error) {
p.mu.RLock()
handler, ok := p.handlers[actionType]
p.mu.RUnlock()
if !ok {
return nil, fmt.Errorf("unknown action handler: %s", actionType)
}
return handler(ctx, entry, params)
}
func (p *Pool) worker(ctx context.Context, id int) {
defer p.wg.Done()
for {
select {
case <-ctx.Done():
return
case task, ok := <-p.queue:
if !ok {
return
}
p.processTask(ctx, task)
}
}
}
func (p *Pool) processTask(ctx context.Context, task Task) {
p.mu.RLock()
handler, ok := p.handlers[task.Action.Type]
p.mu.RUnlock()
if !ok {
p.log.Warn("No handler registered for action", zap.String("type", task.Action.Type))
return
}
start := time.Now()
res, err := handler(ctx, task.Entry, task.Action.Params)
duration := time.Since(start)
if err != nil {
p.log.Error("Action execution failed",
zap.String("action", task.Action.Type),
zap.String("entry_id", task.Entry.ID),
zap.Duration("duration", duration),
zap.Error(err),
)
return
}
if res == nil {
return
}
// 1. Save Variant if returned
if res.ResultVariant != nil {
if err := p.db.SetVariant(*res.ResultVariant); err != nil {
p.log.Error("Failed to save entry variant",
zap.String("entry_id", task.Entry.ID),
zap.String("variant_key", res.ResultVariant.VariantKey),
zap.Error(err),
)
} else {
p.log.Info("Entry variant saved",
zap.String("entry_id", task.Entry.ID),
zap.String("variant_key", res.ResultVariant.VariantKey),
zap.Duration("duration", duration),
)
// Publish EventVariantUpdated to enable downstream chained triggers
if p.bus != nil {
p.bus.Publish(ctx, events.Event{
Type: events.EventVariantUpdated,
Entry: task.Entry,
VariantKey: res.ResultVariant.VariantKey,
Depth: task.Depth,
})
}
}
}
// 2. Apply Read/Star updates
if res.MarkRead != nil {
_ = p.db.UpdateEntryRead(task.Entry.ID, *res.MarkRead)
}
if res.MarkStarred != nil {
_ = p.db.UpdateEntryStarred(task.Entry.ID, *res.MarkStarred)
}
}