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
+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)