Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
367 lines
11 KiB
Go
367 lines
11 KiB
Go
package rss
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
htmltomd "github.com/JohannesKaufmann/html-to-markdown/v2"
|
|
"github.com/Tencent/WeKnora/internal/datasource"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/mmcdole/gofeed"
|
|
)
|
|
|
|
// Compile-time proof that *Connector satisfies the datasource.Connector interface.
|
|
var _ datasource.Connector = (*Connector)(nil)
|
|
|
|
// Connector implements datasource.Connector for RSS/Atom/JSON feeds.
|
|
type Connector struct{}
|
|
|
|
// NewConnector creates a new RSS connector.
|
|
func NewConnector() *Connector { return &Connector{} }
|
|
|
|
// Type returns the connector type identifier.
|
|
func (c *Connector) Type() string { return types.ConnectorTypeRSS }
|
|
|
|
// Validate verifies that every configured feed URL is reachable and parses as
|
|
// a valid feed.
|
|
func (c *Connector) Validate(ctx context.Context, config *types.DataSourceConfig) error {
|
|
cfg, err := parseConfig(config)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cli := newClient(cfg.parseHeaders())
|
|
parser := gofeed.NewParser()
|
|
|
|
for _, feedURL := range cfg.feedURLList() {
|
|
data, err := cli.fetchFeed(ctx, feedURL)
|
|
if err != nil {
|
|
return fmt.Errorf("fetch feed %s: %w", feedURL, err)
|
|
}
|
|
if _, err := parser.Parse(bytes.NewReader(data)); err != nil {
|
|
return fmt.Errorf("parse feed %s: %w", feedURL, err)
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ResolveResourceAncestors has nothing to do: feeds are a flat list with no
|
|
// nesting, so a selection has no ancestors to reveal.
|
|
func (c *Connector) ResolveResourceAncestors(
|
|
ctx context.Context, config *types.DataSourceConfig, resourceIDs []string,
|
|
) ([]string, error) {
|
|
return []string{}, nil
|
|
}
|
|
|
|
// ListResources returns one resource per configured feed URL. The feed is
|
|
// fetched so the resource can carry its real title; a feed that fails to fetch
|
|
// still appears (named by URL) with an error note, so the user can deselect it
|
|
// instead of the whole listing failing.
|
|
func (c *Connector) ListResources(
|
|
ctx context.Context, config *types.DataSourceConfig, parentID string,
|
|
) ([]types.Resource, error) {
|
|
// Feeds are flat: a lazy-load request for a specific parent has nothing extra.
|
|
if parentID != "" {
|
|
return []types.Resource{}, nil
|
|
}
|
|
|
|
cfg, err := parseConfig(config)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
cli := newClient(cfg.parseHeaders())
|
|
parser := gofeed.NewParser()
|
|
|
|
feedURLs := cfg.feedURLList()
|
|
out := make([]types.Resource, 0, len(feedURLs))
|
|
for _, feedURL := range feedURLs {
|
|
res := types.Resource{
|
|
ExternalID: feedURL,
|
|
Type: "feed",
|
|
Name: feedURL,
|
|
URL: feedURL,
|
|
}
|
|
data, err := cli.fetchFeed(ctx, feedURL)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "[RSS] list: fetch %s failed: %v", feedURL, err)
|
|
res.Description = "fetch failed: " + err.Error()
|
|
out = append(out, res)
|
|
continue
|
|
}
|
|
feed, err := parser.Parse(bytes.NewReader(data))
|
|
if err != nil {
|
|
logger.Warnf(ctx, "[RSS] list: parse %s failed: %v", feedURL, err)
|
|
res.Description = "parse failed: " + err.Error()
|
|
out = append(out, res)
|
|
continue
|
|
}
|
|
if title := strings.TrimSpace(feed.Title); title != "" {
|
|
res.Name = title
|
|
}
|
|
res.Description = strings.TrimSpace(feed.Description)
|
|
if feed.Link != "" {
|
|
res.URL = feed.Link
|
|
}
|
|
if feed.UpdatedParsed != nil {
|
|
res.ModifiedAt = *feed.UpdatedParsed
|
|
}
|
|
res.Metadata = map[string]interface{}{"item_count": len(feed.Items)}
|
|
out = append(out, res)
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
// FetchAll performs a full sync of the specified feeds (or all configured feeds
|
|
// when resourceIDs is empty).
|
|
func (c *Connector) FetchAll(
|
|
ctx context.Context, config *types.DataSourceConfig, resourceIDs []string,
|
|
) ([]types.FetchedItem, error) {
|
|
items, _, err := c.walk(ctx, config, resourceIDs, nil, false)
|
|
return items, err
|
|
}
|
|
|
|
// FetchIncremental returns only items whose content fingerprint changed since
|
|
// the prior cursor. Deletions are intentionally not emitted (feeds drop old
|
|
// items as a matter of course).
|
|
func (c *Connector) FetchIncremental(
|
|
ctx context.Context, config *types.DataSourceConfig, cursor *types.SyncCursor,
|
|
) ([]types.FetchedItem, *types.SyncCursor, error) {
|
|
var prev *rssCursor
|
|
if cursor != nil && cursor.ConnectorCursor != nil {
|
|
var p rssCursor
|
|
b, err := json.Marshal(cursor.ConnectorCursor)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "[RSS] marshal connector cursor: %v", err)
|
|
} else if err := json.Unmarshal(b, &p); err != nil {
|
|
logger.Warnf(ctx, "[RSS] unmarshal connector cursor: %v", err)
|
|
} else {
|
|
prev = &p
|
|
}
|
|
}
|
|
|
|
items, newCursor, err := c.walk(ctx, config, config.ResourceIDs, prev, true)
|
|
if err != nil && newCursor == nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
cursorMap := make(map[string]interface{})
|
|
if newCursor != nil {
|
|
b, marshalErr := json.Marshal(newCursor)
|
|
if marshalErr != nil {
|
|
logger.Warnf(ctx, "[RSS] marshal new cursor: %v", marshalErr)
|
|
} else if unmarshalErr := json.Unmarshal(b, &cursorMap); unmarshalErr != nil {
|
|
logger.Warnf(ctx, "[RSS] unmarshal new cursor to map: %v", unmarshalErr)
|
|
}
|
|
}
|
|
|
|
var syncCursor *types.SyncCursor
|
|
if newCursor != nil {
|
|
syncCursor = &types.SyncCursor{
|
|
LastSyncTime: newCursor.LastSyncTime,
|
|
ConnectorCursor: cursorMap,
|
|
}
|
|
}
|
|
|
|
return items, syncCursor, err
|
|
}
|
|
|
|
// walk is the shared implementation for FetchAll / FetchIncremental.
|
|
//
|
|
// When incremental is true, items whose feed signal and content fingerprint are
|
|
// both unchanged are omitted from the result (ingest skipped) without fetching
|
|
// article pages. The returned cursor always reflects the best-known item state
|
|
// so a later sync can detect changes.
|
|
func (c *Connector) walk(
|
|
ctx context.Context,
|
|
config *types.DataSourceConfig,
|
|
resourceIDs []string,
|
|
prev *rssCursor,
|
|
incremental bool,
|
|
) ([]types.FetchedItem, *rssCursor, error) {
|
|
cfg, err := parseConfig(config)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
// Default to all configured feeds when no explicit selection was made.
|
|
feedURLs := resourceIDs
|
|
if len(feedURLs) == 0 {
|
|
feedURLs = cfg.feedURLList()
|
|
}
|
|
|
|
cli := newClient(cfg.parseHeaders())
|
|
parser := gofeed.NewParser()
|
|
|
|
newCursor := &rssCursor{
|
|
LastSyncTime: time.Now().UTC(),
|
|
FeedItems: make(map[string]map[string]string),
|
|
FeedSignals: make(map[string]map[string]string),
|
|
}
|
|
var out []types.FetchedItem
|
|
var feedErrors []string
|
|
|
|
for _, feedURL := range feedURLs {
|
|
data, err := cli.fetchFeed(ctx, feedURL)
|
|
if err != nil {
|
|
logger.Warnf(ctx, "[RSS] sync: fetch %s failed: %v", feedURL, err)
|
|
feedErrors = append(feedErrors, fmt.Sprintf("%s: %v", feedURL, err))
|
|
copyFeedCursor(newCursor, prev, feedURL)
|
|
continue
|
|
}
|
|
feed, err := parser.Parse(bytes.NewReader(data))
|
|
if err != nil {
|
|
logger.Warnf(ctx, "[RSS] sync: parse %s failed: %v", feedURL, err)
|
|
feedErrors = append(feedErrors, fmt.Sprintf("%s: %v", feedURL, err))
|
|
copyFeedCursor(newCursor, prev, feedURL)
|
|
continue
|
|
}
|
|
|
|
newCursor.FeedItems[feedURL] = make(map[string]string)
|
|
newCursor.FeedSignals[feedURL] = make(map[string]string)
|
|
var prevItems map[string]string
|
|
var prevSignals map[string]string
|
|
if incremental && prev != nil {
|
|
prevItems = prev.FeedItems[feedURL]
|
|
if prev.FeedSignals != nil {
|
|
prevSignals = prev.FeedSignals[feedURL]
|
|
}
|
|
}
|
|
|
|
var kept, skipped int
|
|
for _, item := range feed.Items {
|
|
if item == nil {
|
|
continue
|
|
}
|
|
itemID := firstNonEmpty(item.GUID, item.Link, item.Title)
|
|
if itemID == "" {
|
|
continue
|
|
}
|
|
|
|
feedContent := firstNonEmpty(item.Content, item.Description)
|
|
feedSig := feedSignalFingerprint(item, feedContent)
|
|
|
|
if incremental && prevItems != nil {
|
|
prevFP := prevItems[itemID]
|
|
prevSig := ""
|
|
if prevSignals != nil {
|
|
prevSig = prevSignals[itemID]
|
|
}
|
|
if prevFP != "" && feedSig == prevSig {
|
|
newCursor.FeedItems[feedURL][itemID] = prevFP
|
|
newCursor.FeedSignals[feedURL][itemID] = feedSig
|
|
skipped++
|
|
continue
|
|
}
|
|
}
|
|
|
|
resolved := c.resolveItem(ctx, cli, feed, item, feedURL, itemID, feedContent)
|
|
newCursor.FeedItems[feedURL][itemID] = resolved.fingerprint
|
|
newCursor.FeedSignals[feedURL][itemID] = feedSig
|
|
|
|
if incremental && prevItems != nil && prevItems[itemID] == resolved.fingerprint {
|
|
skipped++
|
|
continue
|
|
}
|
|
kept++
|
|
out = append(out, resolved.item)
|
|
}
|
|
|
|
logger.Infof(ctx, "[RSS] feed %s: items=%d fetched=%d skipped=%d",
|
|
feedURL, len(feed.Items), kept, skipped)
|
|
}
|
|
|
|
if len(feedErrors) > 0 {
|
|
if len(out) == 0 && len(feedErrors) == len(feedURLs) {
|
|
return nil, newCursor, fmt.Errorf("all feeds failed: %s", strings.Join(feedErrors, "; "))
|
|
}
|
|
return out, newCursor, &datasource.PartialFetchError{Details: feedErrors}
|
|
}
|
|
|
|
return out, newCursor, nil
|
|
}
|
|
|
|
type resolvedFeedItem struct {
|
|
item types.FetchedItem
|
|
fingerprint string
|
|
}
|
|
|
|
// resolveItem assembles a FetchedItem for a single feed entry, resolving the
|
|
// best available content (full-text article > feed content) and converting it
|
|
// to Markdown.
|
|
func (c *Connector) resolveItem(
|
|
ctx context.Context,
|
|
cli *client,
|
|
feed *gofeed.Feed,
|
|
item *gofeed.Item,
|
|
feedURL, itemID, feedContent string,
|
|
) resolvedFeedItem {
|
|
title := firstNonEmpty(item.Title, "untitled")
|
|
|
|
// Prefer full article text; fall back to feed-provided content on failure.
|
|
contentHTML := feedContent
|
|
if strings.TrimSpace(item.Link) != "" {
|
|
if articleHTML, articleTitle, err := cli.extractArticle(ctx, item.Link); err == nil {
|
|
contentHTML = articleHTML
|
|
if item.Title == "" && articleTitle != "" {
|
|
title = articleTitle
|
|
}
|
|
} else {
|
|
logger.Warnf(ctx, "[RSS] full-text fetch failed for %s (using feed content): %v", item.Link, err)
|
|
}
|
|
}
|
|
|
|
content := htmlToMarkdown(contentHTML)
|
|
|
|
updatedAt := time.Now().UTC()
|
|
switch {
|
|
case item.UpdatedParsed != nil && !item.UpdatedParsed.IsZero():
|
|
updatedAt = *item.UpdatedParsed
|
|
case item.PublishedParsed != nil && !item.PublishedParsed.IsZero():
|
|
updatedAt = *item.PublishedParsed
|
|
}
|
|
|
|
author := ""
|
|
if item.Author != nil {
|
|
author = item.Author.Name
|
|
}
|
|
|
|
return resolvedFeedItem{
|
|
fingerprint: contentFingerprint(content),
|
|
item: types.FetchedItem{
|
|
ExternalID: itemExternalID(feedURL, itemID),
|
|
Title: title,
|
|
Content: []byte(content),
|
|
ContentType: "text/markdown",
|
|
FileName: sanitizeFileName(title) + ".md",
|
|
URL: item.Link,
|
|
UpdatedAt: updatedAt,
|
|
SourceResourceID: feedURL,
|
|
Metadata: map[string]string{
|
|
"channel": types.ChannelRSS,
|
|
"feed_url": feedURL,
|
|
"feed_title": feed.Title,
|
|
"guid": item.GUID,
|
|
"link": item.Link,
|
|
"author": author,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
// htmlToMarkdown converts HTML to Markdown, returning the trimmed original on
|
|
// conversion failure so we never silently drop content.
|
|
func htmlToMarkdown(html string) string {
|
|
if strings.TrimSpace(html) == "" {
|
|
return ""
|
|
}
|
|
md, err := htmltomd.ConvertString(html)
|
|
if err != nil || strings.TrimSpace(md) == "" {
|
|
return strings.TrimSpace(html)
|
|
}
|
|
return strings.TrimSpace(md)
|
|
}
|