mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-10 01:11:23 +08:00
### Summary As title --------- Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
539 lines
14 KiB
Go
539 lines
14 KiB
Go
//
|
|
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
|
|
//
|
|
// Licensed under the Apache License, Version 2.0 (the "License");
|
|
// you may not use this file except in compliance with the License.
|
|
// You may obtain a copy of the License at
|
|
//
|
|
// http://www.apache.org/licenses/LICENSE-2.0
|
|
//
|
|
// Unless required by applicable law or agreed to in writing, software
|
|
// distributed under the License is distributed on an "AS IS" BASIS,
|
|
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
// See the License for the specific language governing permissions and
|
|
// limitations under the License.
|
|
//
|
|
|
|
package connector
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"crypto/md5"
|
|
"encoding/hex"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"html"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"ragflow/internal/utility"
|
|
|
|
"github.com/zeebo/xxh3"
|
|
)
|
|
|
|
const (
|
|
defaultRSSBatchSize = 32
|
|
maxRSSFeedSize = 32 * 1024 * 1024
|
|
rssFetchTimeout = 60 * time.Second
|
|
rssFetchRetryCount = 3
|
|
)
|
|
|
|
var (
|
|
rssHTMLScriptStyleRE = regexp.MustCompile(`(?is)<(script|style)[^>]*>.*?</(script|style)>`)
|
|
rssHTMLTagRE = regexp.MustCompile(`(?s)<[^>]+>`)
|
|
rssWhitespaceRE = regexp.MustCompile(`[ \t]+`)
|
|
rssNewlineRE = regexp.MustCompile(`\n{3,}`)
|
|
)
|
|
|
|
// RSSConnector reads RSS and Atom feeds.
|
|
type RSSConnector struct {
|
|
feedURL string
|
|
batchSize int
|
|
fetchFeed func(ctx context.Context, feedURL string) ([]byte, error)
|
|
entries []rssEntry
|
|
}
|
|
|
|
// NewRSSConnector creates an RSS connector from Python-compatible config.
|
|
func NewRSSConnector(config map[string]any) (*RSSConnector, error) {
|
|
feedURL, _ := config["feed_url"].(string)
|
|
batchSize := configInt(config["batch_size"], defaultRSSBatchSize)
|
|
return &RSSConnector{feedURL: strings.TrimSpace(feedURL), batchSize: batchSize}, nil
|
|
}
|
|
|
|
// Validate validates RSS connector settings and feed readability.
|
|
func (c *RSSConnector) Validate(ctx context.Context) error {
|
|
if c == nil {
|
|
return fmt.Errorf("rss connector is nil")
|
|
}
|
|
if c.feedURL == "" {
|
|
return fmt.Errorf("RSS feed URL is required")
|
|
}
|
|
|
|
parsed, err := url.Parse(c.feedURL)
|
|
if err != nil || parsed.Scheme == "" || parsed.Host == "" {
|
|
return fmt.Errorf("invalid RSS feed URL")
|
|
}
|
|
if parsed.Scheme != "http" && parsed.Scheme != "https" {
|
|
return fmt.Errorf("RSS feed URL must use HTTP or HTTPS")
|
|
}
|
|
if c.batchSize <= 0 {
|
|
return fmt.Errorf("batch_size must be a positive integer")
|
|
}
|
|
|
|
entries, err := c.loadEntries(ctx)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(entries) == 0 {
|
|
return fmt.Errorf("RSS feed contains no entries")
|
|
}
|
|
|
|
c.entries = entries
|
|
return nil
|
|
}
|
|
|
|
// OpenSync opens one RSS sync session.
|
|
func (c *RSSConnector) OpenSync(ctx context.Context, request SyncRequest) (SyncSession, error) {
|
|
entries, err := c.loadEntries(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
documents := make([]SourceDocument, 0, len(entries))
|
|
for _, entry := range entries {
|
|
if !request.WindowEnd.IsZero() && entry.updatedAt.After(request.WindowEnd) {
|
|
continue
|
|
}
|
|
if !request.FromBeginning && request.WindowStart != nil { // not reindex
|
|
if !entry.updatedAt.After(*request.WindowStart) {
|
|
continue
|
|
}
|
|
}
|
|
documents = append(documents, entry.toSourceDocument(c.feedURL))
|
|
}
|
|
return &rssSyncSession{documents: documents, batchSize: c.batchSize}, nil
|
|
}
|
|
|
|
// OpenPrune opens one complete RSS prune snapshot session.
|
|
func (c *RSSConnector) OpenPrune(ctx context.Context, request PruneRequest) (PruneSession, error) {
|
|
entries, err := c.loadEntries(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
documents := make([]SlimDocument, 0, len(entries))
|
|
for _, entry := range entries {
|
|
documents = append(documents, SlimDocument{SourceID: entry.sourceID()})
|
|
}
|
|
return &rssPruneSession{documents: documents, batchSize: c.batchSize}, nil
|
|
}
|
|
|
|
// loadEntries fetches and parses the configured feed.
|
|
func (c *RSSConnector) loadEntries(ctx context.Context) ([]rssEntry, error) {
|
|
if c.entries != nil {
|
|
return c.entries, nil
|
|
}
|
|
if c.fetchFeed != nil {
|
|
data, err := c.fetchFeed(ctx, c.feedURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return parseRSSFeed(data)
|
|
}
|
|
data, _, _, err := fetchRSSFeedWithRetry(ctx, c.feedURL)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
entries, err := parseRSSFeed(data)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// fetchRSSFeedWithRetry fetches a feed with bounded retries for transient slowness.
|
|
func fetchRSSFeedWithRetry(ctx context.Context, feedURL string) ([]byte, http.Header, string, error) {
|
|
var lastErr error
|
|
for attempt := 1; attempt <= rssFetchRetryCount; attempt++ {
|
|
data, headers, finalURL, err := utility.FetchRemoteFileSafelyWithTimeout(ctx, feedURL, maxRSSFeedSize, rssFetchTimeout)
|
|
if err == nil {
|
|
return data, headers, finalURL, nil
|
|
}
|
|
lastErr = err
|
|
if attempt == rssFetchRetryCount {
|
|
break
|
|
}
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, nil, "", ctx.Err()
|
|
case <-time.After(time.Duration(attempt) * time.Second):
|
|
}
|
|
}
|
|
return nil, nil, "", lastErr
|
|
}
|
|
|
|
type rssSyncSession struct {
|
|
documents []SourceDocument
|
|
batchSize int
|
|
index int
|
|
}
|
|
|
|
// NextBatch returns the next RSS document batch.
|
|
func (s *rssSyncSession) NextBatch(ctx context.Context) (SyncBatch, error) {
|
|
if s.index >= len(s.documents) {
|
|
return SyncBatch{}, io.EOF
|
|
}
|
|
end := s.index + s.batchSize
|
|
if end > len(s.documents) {
|
|
end = len(s.documents)
|
|
}
|
|
batch := SyncBatch{Documents: s.documents[s.index:end]}
|
|
s.index = end
|
|
return batch, nil
|
|
}
|
|
|
|
// Close closes the RSS sync session.
|
|
func (s *rssSyncSession) Close() error {
|
|
return nil
|
|
}
|
|
|
|
type rssPruneSession struct {
|
|
documents []SlimDocument
|
|
batchSize int
|
|
index int
|
|
}
|
|
|
|
// NextBatch returns the next RSS prune snapshot batch.
|
|
func (s *rssPruneSession) NextBatch(ctx context.Context) (PruneBatch, error) {
|
|
if s.index >= len(s.documents) {
|
|
return PruneBatch{}, io.EOF
|
|
}
|
|
end := s.index + s.batchSize
|
|
if end > len(s.documents) {
|
|
end = len(s.documents)
|
|
}
|
|
batch := PruneBatch{Documents: s.documents[s.index:end]}
|
|
s.index = end
|
|
return batch, nil
|
|
}
|
|
|
|
// Close closes the RSS prune session.
|
|
func (s *rssPruneSession) Close() error {
|
|
return nil
|
|
}
|
|
|
|
type rssEntry struct {
|
|
id string
|
|
link string
|
|
title string
|
|
author string
|
|
categories []string
|
|
content string
|
|
summary string
|
|
updatedAt time.Time
|
|
}
|
|
|
|
// sourceID returns the RSS source document ID.
|
|
func (e rssEntry) sourceID() string {
|
|
stable := firstNonEmpty(e.id, e.link, e.title)
|
|
sum := md5.Sum([]byte(stable))
|
|
return "rss:" + hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// semanticIdentifier returns the human-readable document name seed.
|
|
func (e rssEntry) semanticIdentifier() string {
|
|
return firstNonEmpty(e.title, e.link, e.id, "rss-entry")
|
|
}
|
|
|
|
// toSourceDocument converts one feed entry into the syncer model.
|
|
func (e rssEntry) toSourceDocument(feedURL string) SourceDocument {
|
|
body := e.renderText()
|
|
fingerprint := rssFingerprint(body)
|
|
metadata := map[string]any{"feed_url": feedURL}
|
|
if e.link != "" {
|
|
metadata["link"] = e.link
|
|
}
|
|
if e.author != "" {
|
|
metadata["author"] = e.author
|
|
}
|
|
if len(e.categories) > 0 {
|
|
metadata["categories"] = e.categories
|
|
}
|
|
return SourceDocument{
|
|
SourceID: e.sourceID(),
|
|
SemanticIdentifier: e.semanticIdentifier(),
|
|
Extension: ".txt",
|
|
Blob: []byte(body),
|
|
UpdatedAt: e.updatedAt,
|
|
SizeBytes: int64(len(body)),
|
|
Metadata: metadata,
|
|
Fingerprint: fingerprint,
|
|
}
|
|
}
|
|
|
|
// renderText builds the plain-text RSS document body.
|
|
func (e rssEntry) renderText() string {
|
|
parts := []string{e.semanticIdentifier()}
|
|
body := htmlToText(firstNonEmpty(e.content, e.summary))
|
|
if body != "" {
|
|
parts = append(parts, body)
|
|
}
|
|
return strings.Join(parts, "\n\n")
|
|
}
|
|
|
|
type rssRoot struct {
|
|
XMLName xml.Name
|
|
Channel rssChannel `xml:"channel"`
|
|
}
|
|
|
|
type rssChannel struct {
|
|
Items []rssXMLItem `xml:"item"`
|
|
}
|
|
|
|
type rssXMLItem struct {
|
|
GUID string `xml:"guid"`
|
|
Title string `xml:"title"`
|
|
Link string `xml:"link"`
|
|
Author string `xml:"author"`
|
|
Creator string `xml:"creator"`
|
|
PubDate string `xml:"pubDate"`
|
|
Published string `xml:"published"`
|
|
Updated string `xml:"updated"`
|
|
Description string `xml:"description"`
|
|
Summary string `xml:"summary"`
|
|
Encoded string `xml:"encoded"`
|
|
Categories []rssCategory `xml:"category"`
|
|
}
|
|
|
|
type rssCategory struct {
|
|
Term string `xml:"term,attr"`
|
|
Label string `xml:"label,attr"`
|
|
Text string `xml:",chardata"`
|
|
}
|
|
|
|
type atomFeed struct {
|
|
Entries []atomEntry `xml:"entry"`
|
|
}
|
|
|
|
type atomEntry struct {
|
|
ID string `xml:"id"`
|
|
Title string `xml:"title"`
|
|
Updated string `xml:"updated"`
|
|
Published string `xml:"published"`
|
|
Summary atomText `xml:"summary"`
|
|
Content atomText `xml:"content"`
|
|
Links []atomLink `xml:"link"`
|
|
Author atomPerson `xml:"author"`
|
|
Categories []atomCategory `xml:"category"`
|
|
}
|
|
|
|
type atomText struct {
|
|
Text string `xml:",innerxml"`
|
|
}
|
|
|
|
type atomLink struct {
|
|
Href string `xml:"href,attr"`
|
|
Rel string `xml:"rel,attr"`
|
|
}
|
|
|
|
type atomPerson struct {
|
|
Name string `xml:"name"`
|
|
}
|
|
|
|
type atomCategory struct {
|
|
Term string `xml:"term,attr"`
|
|
Label string `xml:"label,attr"`
|
|
}
|
|
|
|
// parseRSSFeed parses RSS 2.0 and Atom payloads.
|
|
func parseRSSFeed(data []byte) ([]rssEntry, error) {
|
|
decoder := xml.NewDecoder(bytes.NewReader(data))
|
|
for {
|
|
token, err := decoder.Token()
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to parse RSS feed: %w", err)
|
|
}
|
|
if start, ok := token.(xml.StartElement); ok {
|
|
switch strings.ToLower(start.Name.Local) {
|
|
case "rss", "rdf":
|
|
return parseRSSXML(data)
|
|
case "feed":
|
|
return parseAtomXML(data)
|
|
default:
|
|
return nil, fmt.Errorf("failed to parse RSS feed")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// parseRSSXML parses an RSS-style feed document.
|
|
func parseRSSXML(data []byte) ([]rssEntry, error) {
|
|
var root rssRoot
|
|
if err := xml.Unmarshal(data, &root); err != nil {
|
|
return nil, fmt.Errorf("failed to parse RSS feed: %w", err)
|
|
}
|
|
entries := make([]rssEntry, 0, len(root.Channel.Items))
|
|
for _, item := range root.Channel.Items {
|
|
entry := rssEntry{
|
|
id: strings.TrimSpace(item.GUID),
|
|
link: strings.TrimSpace(item.Link),
|
|
title: strings.TrimSpace(item.Title),
|
|
author: firstNonEmpty(item.Author, item.Creator),
|
|
categories: rssCategories(item.Categories),
|
|
content: firstNonEmpty(item.Encoded, item.Description),
|
|
summary: firstNonEmpty(item.Summary, item.Description),
|
|
updatedAt: parseFeedTime(firstNonEmpty(item.Updated, item.Published, item.PubDate)),
|
|
}
|
|
entries = append(entries, entry.withDefaults())
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// parseAtomXML parses an Atom feed document.
|
|
func parseAtomXML(data []byte) ([]rssEntry, error) {
|
|
var feed atomFeed
|
|
if err := xml.Unmarshal(data, &feed); err != nil {
|
|
return nil, fmt.Errorf("failed to parse RSS feed: %w", err)
|
|
}
|
|
entries := make([]rssEntry, 0, len(feed.Entries))
|
|
for _, item := range feed.Entries {
|
|
entry := rssEntry{
|
|
id: strings.TrimSpace(item.ID),
|
|
link: atomEntryLink(item.Links),
|
|
title: htmlToText(item.Title),
|
|
author: strings.TrimSpace(item.Author.Name),
|
|
categories: atomCategories(item.Categories),
|
|
content: item.Content.Text,
|
|
summary: item.Summary.Text,
|
|
updatedAt: parseFeedTime(firstNonEmpty(item.Updated, item.Published)),
|
|
}
|
|
entries = append(entries, entry.withDefaults())
|
|
}
|
|
return entries, nil
|
|
}
|
|
|
|
// withDefaults fills missing stable fields with deterministic fallbacks.
|
|
func (e rssEntry) withDefaults() rssEntry {
|
|
return e
|
|
}
|
|
|
|
// rssFingerprint returns a deterministic body fingerprint.
|
|
func rssFingerprint(body string) string {
|
|
sum := xxh3.Hash128([]byte(body)).Bytes()
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
// rssCategories returns RSS category labels.
|
|
func rssCategories(categories []rssCategory) []string {
|
|
out := make([]string, 0, len(categories))
|
|
for _, category := range categories {
|
|
value := firstNonEmpty(category.Term, category.Label, category.Text)
|
|
if value != "" {
|
|
out = append(out, value)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// atomCategories returns Atom category labels.
|
|
func atomCategories(categories []atomCategory) []string {
|
|
out := make([]string, 0, len(categories))
|
|
for _, category := range categories {
|
|
value := firstNonEmpty(category.Label, category.Term)
|
|
if value != "" {
|
|
out = append(out, value)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
// atomEntryLink returns the preferred Atom link.
|
|
func atomEntryLink(links []atomLink) string {
|
|
for _, link := range links {
|
|
if link.Href != "" && (link.Rel == "" || link.Rel == "alternate") {
|
|
return strings.TrimSpace(link.Href)
|
|
}
|
|
}
|
|
if len(links) > 0 {
|
|
return strings.TrimSpace(links[0].Href)
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// htmlToText removes markup and normalizes text spacing.
|
|
func htmlToText(value string) string {
|
|
value = rssHTMLScriptStyleRE.ReplaceAllString(value, "")
|
|
value = rssHTMLTagRE.ReplaceAllString(value, "\n")
|
|
value = html.UnescapeString(value)
|
|
value = strings.ReplaceAll(value, "\r\n", "\n")
|
|
value = strings.ReplaceAll(value, "\r", "\n")
|
|
lines := strings.Split(value, "\n")
|
|
for i, line := range lines {
|
|
lines[i] = strings.TrimSpace(rssWhitespaceRE.ReplaceAllString(line, " "))
|
|
}
|
|
value = strings.Join(lines, "\n")
|
|
value = rssNewlineRE.ReplaceAllString(value, "\n\n")
|
|
return strings.TrimSpace(value)
|
|
}
|
|
|
|
// parseFeedTime parses common RSS and Atom time formats.
|
|
func parseFeedTime(value string) time.Time {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return time.Time{}
|
|
}
|
|
layouts := []string{
|
|
time.RFC1123Z,
|
|
time.RFC1123,
|
|
time.RFC3339Nano,
|
|
time.RFC3339,
|
|
"Mon, 02 Jan 2006 15:04:05 -0700",
|
|
"Mon, 2 Jan 2006 15:04:05 -0700",
|
|
"2006-01-02 15:04:05",
|
|
}
|
|
for _, layout := range layouts {
|
|
if parsed, err := time.Parse(layout, value); err == nil {
|
|
return parsed.UTC()
|
|
}
|
|
}
|
|
return time.Time{}
|
|
}
|
|
|
|
// firstNonEmpty returns the first non-empty trimmed string.
|
|
func firstNonEmpty(values ...string) string {
|
|
for _, value := range values {
|
|
if strings.TrimSpace(value) != "" {
|
|
return strings.TrimSpace(value)
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// configInt reads a positive integer-like config value.
|
|
func configInt(value any, fallback int) int {
|
|
switch typed := value.(type) {
|
|
case int:
|
|
if typed > 0 {
|
|
return typed
|
|
}
|
|
case int64:
|
|
if typed > 0 {
|
|
return int(typed)
|
|
}
|
|
case float64:
|
|
if typed > 0 {
|
|
return int(typed)
|
|
}
|
|
case string:
|
|
if parsed, err := strconv.Atoi(strings.TrimSpace(typed)); err == nil && parsed > 0 {
|
|
return parsed
|
|
}
|
|
}
|
|
return fallback
|
|
}
|