diff --git a/apps/web_v1/scripts/visual-check.mjs b/apps/web_v1/scripts/visual-check.mjs index 8f604f0..8098751 100644 --- a/apps/web_v1/scripts/visual-check.mjs +++ b/apps/web_v1/scripts/visual-check.mjs @@ -265,16 +265,16 @@ await page.route('http://localhost:9150/api/v1/**', async (route) => { } if (url.pathname === '/api/v1/dataset-crons/sync') { await route.fulfill({ - status: 202, + status: 200, json: visualDatasetSources.map((source, index) => ({ id: `019b0000-0000-7000-8000-${String(70 + index).padStart(12, '0')}`, sourceId: source.id, schedule: '@once', - status: 'pending', - enabled: true, - nextRunAt: new Date().toISOString(), - lastRunAt: null, - lastResult: '', + status: 'completed', + enabled: false, + nextRunAt: null, + lastRunAt: new Date().toISOString(), + lastResult: 'format=rss fetched=2 inserted=0', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), })), diff --git a/apps/web_v1/src/pages/workspace-explore.tsx b/apps/web_v1/src/pages/workspace-explore.tsx index 4e92f84..468a1ca 100644 --- a/apps/web_v1/src/pages/workspace-explore.tsx +++ b/apps/web_v1/src/pages/workspace-explore.tsx @@ -231,12 +231,20 @@ export function WorkspaceExplorePage({ try { const crons = await onQueueSync() if (crons.length === 0) { - Message.info('暂无已启用的数据源') + Message.info('暂无已启用的 RSS 数据源') } else { - Message.success(`已提交 ${crons.length} 个采集任务`) + const failed = crons.filter((cron) => cron.status === 'failed').length + const [nextSources, nextItems] = await Promise.all([onListSources(), onListItems()]) + setSourceRecords(nextSources) + setItems(nextItems) + if (failed > 0) { + Message.warning(`${crons.length - failed} 个数据源采集完成,${failed} 个失败`) + } else { + Message.success(`已完成 ${crons.length} 个数据源采集任务`) + } } } catch (error) { - Message.error(error instanceof Error ? error.message : '同步任务提交失败') + Message.error(error instanceof Error ? error.message : '数据源采集失败') } finally { setSyncing(false) } diff --git a/backend/internal/logic/dataset/feed.go b/backend/internal/logic/dataset/feed.go new file mode 100644 index 0000000..9a0dd72 --- /dev/null +++ b/backend/internal/logic/dataset/feed.go @@ -0,0 +1,347 @@ +package dataset + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "encoding/xml" + "errors" + "fmt" + "html" + "io" + "net" + "net/http" + "net/url" + "regexp" + "strings" + "time" +) + +const maxFeedBytes = 5 << 20 + +var ( + ErrFeedFormat = errors.New("unsupported feed format") + ErrFeedTooLarge = errors.New("feed response is too large") + ErrUnsafeFeedURL = errors.New("feed url resolves to a private or local address") + htmlTagPattern = regexp.MustCompile(`<[^>]*>`) +) + +type FeedItem struct { + ExternalID string + Title string + Summary string + Content string + URL string + PublishedAt *time.Time +} + +type ParsedFeed struct { + Format string + Items []FeedItem +} + +type FeedFetcher interface { + Fetch(context.Context, string) (ParsedFeed, error) +} + +type HTTPFeedFetcher struct { + client *http.Client +} + +func NewHTTPFeedFetcher() *HTTPFeedFetcher { + transport := &http.Transport{ + DialContext: func(ctx context.Context, network, address string) (net.Conn, error) { + host, port, err := net.SplitHostPort(address) + if err != nil { + return nil, err + } + ips, err := net.DefaultResolver.LookupIPAddr(ctx, host) + if err != nil { + return nil, err + } + dialer := &net.Dialer{Timeout: 10 * time.Second} + var lastErr error + for _, candidate := range ips { + if !isPublicIP(candidate.IP) { + continue + } + connection, err := dialer.DialContext(ctx, network, net.JoinHostPort(candidate.IP.String(), port)) + if err == nil { + return connection, nil + } + lastErr = err + } + if lastErr != nil { + return nil, lastErr + } + return nil, ErrUnsafeFeedURL + }, + TLSHandshakeTimeout: 10 * time.Second, + ResponseHeaderTimeout: 15 * time.Second, + } + fetcher := &HTTPFeedFetcher{} + fetcher.client = &http.Client{ + Transport: transport, + Timeout: 30 * time.Second, + CheckRedirect: func(request *http.Request, via []*http.Request) error { + if len(via) >= 5 { + return errors.New("too many feed redirects") + } + return validateFeedURL(request.URL) + }, + } + return fetcher +} + +func (f *HTTPFeedFetcher) Fetch(ctx context.Context, value string) (ParsedFeed, error) { + parsedURL, err := url.Parse(value) + if err != nil { + return ParsedFeed{}, err + } + if err := validateFeedURL(parsedURL); err != nil { + return ParsedFeed{}, err + } + request, err := http.NewRequestWithContext(ctx, http.MethodGet, parsedURL.String(), nil) + if err != nil { + return ParsedFeed{}, err + } + request.Header.Set("Accept", "application/feed+json, application/json, application/rss+xml, application/atom+xml, application/xml, text/xml;q=0.9") + request.Header.Set("User-Agent", "SenlinAI-Agent/1.0") + response, err := f.client.Do(request) + if err != nil { + return ParsedFeed{}, err + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + return ParsedFeed{}, fmt.Errorf("feed request returned %s", response.Status) + } + body, err := io.ReadAll(io.LimitReader(response.Body, maxFeedBytes+1)) + if err != nil { + return ParsedFeed{}, err + } + if len(body) > maxFeedBytes { + return ParsedFeed{}, ErrFeedTooLarge + } + return ParseFeed(body, response.Header.Get("Content-Type")) +} + +func ParseFeed(body []byte, contentType string) (ParsedFeed, error) { + body = bytes.TrimSpace(bytes.TrimPrefix(body, []byte{0xef, 0xbb, 0xbf})) + if len(body) == 0 { + return ParsedFeed{}, ErrFeedFormat + } + lowerContentType := strings.ToLower(contentType) + if body[0] == '{' || strings.Contains(lowerContentType, "json") { + if feed, err := parseJSONFeed(body); err == nil { + return feed, nil + } + } + if body[0] == '<' || strings.Contains(lowerContentType, "xml") || strings.Contains(lowerContentType, "rss") || strings.Contains(lowerContentType, "atom") { + return parseXMLFeed(body) + } + return ParsedFeed{}, ErrFeedFormat +} + +type jsonFeedDocument struct { + Version string `json:"version"` + Items []jsonFeedItem `json:"items"` +} + +type jsonFeedItem struct { + ID string `json:"id"` + URL string `json:"url"` + Title string `json:"title"` + Summary string `json:"summary"` + ContentText string `json:"content_text"` + ContentHTML string `json:"content_html"` + DatePublished string `json:"date_published"` +} + +func parseJSONFeed(body []byte) (ParsedFeed, error) { + var document jsonFeedDocument + if err := json.Unmarshal(body, &document); err != nil { + return ParsedFeed{}, err + } + if document.Version == "" { + return ParsedFeed{}, ErrFeedFormat + } + items := make([]FeedItem, 0, len(document.Items)) + for _, input := range document.Items { + content := strings.TrimSpace(input.ContentText) + if content == "" { + content = plainText(input.ContentHTML) + } + item := normalizeFeedItem(FeedItem{ + ExternalID: input.ID, Title: plainText(input.Title), Summary: plainText(input.Summary), + Content: content, URL: input.URL, PublishedAt: parseFeedTime(input.DatePublished), + }) + if item.Title != "" { + items = append(items, item) + } + } + return ParsedFeed{Format: "json_feed", Items: items}, nil +} + +type xmlFeedDocument struct { + XMLName xml.Name + Channel struct { + Items []xmlFeedItem `xml:"item"` + } `xml:"channel"` + Items []xmlFeedItem `xml:"item"` + Entries []xmlAtomEntry `xml:"entry"` +} + +type xmlFeedItem struct { + Title string `xml:"title"` + Link string `xml:"link"` + GUID string `xml:"guid"` + Description string `xml:"description"` + Content string `xml:"encoded"` + PubDate string `xml:"pubDate"` + Date string `xml:"date"` +} + +type xmlAtomEntry struct { + Title string `xml:"title"` + ID string `xml:"id"` + Links []xmlAtomLink `xml:"link"` + Summary string `xml:"summary"` + Content string `xml:"content"` + Published string `xml:"published"` + Updated string `xml:"updated"` +} + +type xmlAtomLink struct { + Href string `xml:"href,attr"` + Rel string `xml:"rel,attr"` +} + +func parseXMLFeed(body []byte) (ParsedFeed, error) { + var document xmlFeedDocument + if err := xml.Unmarshal(body, &document); err != nil { + return ParsedFeed{}, err + } + switch strings.ToLower(document.XMLName.Local) { + case "rss", "rdf": + inputs := document.Channel.Items + if len(inputs) == 0 { + inputs = document.Items + } + items := make([]FeedItem, 0, len(inputs)) + for _, input := range inputs { + content := plainText(input.Content) + if content == "" { + content = plainText(input.Description) + } + item := normalizeFeedItem(FeedItem{ + ExternalID: input.GUID, Title: plainText(input.Title), + Summary: plainText(input.Description), Content: content, + URL: strings.TrimSpace(input.Link), PublishedAt: parseFeedTime(firstNonEmpty(input.PubDate, input.Date)), + }) + if item.Title != "" { + items = append(items, item) + } + } + return ParsedFeed{Format: "rss", Items: items}, nil + case "feed": + items := make([]FeedItem, 0, len(document.Entries)) + for _, input := range document.Entries { + item := normalizeFeedItem(FeedItem{ + ExternalID: input.ID, Title: plainText(input.Title), + Summary: plainText(input.Summary), Content: plainText(input.Content), + URL: atomLink(input.Links), PublishedAt: parseFeedTime(firstNonEmpty(input.Published, input.Updated)), + }) + if item.Title != "" { + items = append(items, item) + } + } + return ParsedFeed{Format: "atom", Items: items}, nil + default: + return ParsedFeed{}, ErrFeedFormat + } +} + +func normalizeFeedItem(item FeedItem) FeedItem { + item.Title = truncateRunes(strings.TrimSpace(item.Title), 500) + item.Summary = strings.TrimSpace(item.Summary) + item.Content = strings.TrimSpace(item.Content) + item.URL = truncateRunes(strings.TrimSpace(item.URL), 2048) + key := firstNonEmpty(strings.TrimSpace(item.ExternalID), item.URL) + if key == "" { + published := "" + if item.PublishedAt != nil { + published = item.PublishedAt.UTC().Format(time.RFC3339Nano) + } + key = item.Title + "\x00" + published + } + sum := sha256.Sum256([]byte(key)) + item.ExternalID = hex.EncodeToString(sum[:]) + return item +} + +func parseFeedTime(value string) *time.Time { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + layouts := []string{ + time.RFC3339Nano, time.RFC3339, time.RFC1123Z, time.RFC1123, + time.RFC822Z, time.RFC822, time.RFC850, time.ANSIC, + "Mon, 2 Jan 2006 15:04:05 -0700", + } + for _, layout := range layouts { + if parsed, err := time.Parse(layout, value); err == nil { + utc := parsed.UTC() + return &utc + } + } + return nil +} + +func plainText(value string) string { + value = htmlTagPattern.ReplaceAllString(value, " ") + return strings.Join(strings.Fields(html.UnescapeString(value)), " ") +} + +func atomLink(links []xmlAtomLink) string { + for _, link := range links { + if link.Rel == "" || link.Rel == "alternate" { + return strings.TrimSpace(link.Href) + } + } + if len(links) > 0 { + return strings.TrimSpace(links[0].Href) + } + return "" +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} + +func validateFeedURL(value *url.URL) error { + if value == nil || (value.Scheme != "http" && value.Scheme != "https") || value.Hostname() == "" || value.User != nil { + return ErrURLInvalid + } + if ip := net.ParseIP(value.Hostname()); ip != nil && !isPublicIP(ip) { + return ErrUnsafeFeedURL + } + return nil +} + +func isPublicIP(ip net.IP) bool { + return ip != nil && + !ip.IsLoopback() && + !ip.IsPrivate() && + !ip.IsLinkLocalUnicast() && + !ip.IsLinkLocalMulticast() && + !ip.IsUnspecified() && + !ip.IsMulticast() +} diff --git a/backend/internal/logic/dataset/feed_test.go b/backend/internal/logic/dataset/feed_test.go new file mode 100644 index 0000000..7e72ec2 --- /dev/null +++ b/backend/internal/logic/dataset/feed_test.go @@ -0,0 +1,126 @@ +package dataset + +import ( + "context" + "errors" + "net/url" + "testing" + + "github.com/stretchr/testify/require" + "senlinai-agent/backend/internal/models" +) + +func TestParseFeedDetectsJSONFeedFromContent(t *testing.T) { + feed, err := ParseFeed([]byte(`{ + "version": "https://jsonfeed.org/version/1.1", + "items": [{ + "id": "json-1", + "url": "https://example.com/json-1", + "title": "JSON item", + "summary": "JSON summary", + "content_html": "
JSON content
", + "date_published": "2026-07-23T08:00:00Z" + }] + }`), "text/plain") + + require.NoError(t, err) + require.Equal(t, "json_feed", feed.Format) + require.Len(t, feed.Items, 1) + require.Equal(t, "JSON item", feed.Items[0].Title) + require.Equal(t, "JSON content", feed.Items[0].Content) + require.Equal(t, "https://example.com/json-1", feed.Items[0].URL) + require.NotEmpty(t, feed.Items[0].ExternalID) + require.NotNil(t, feed.Items[0].PublishedAt) +} + +func TestParseFeedDetectsRSSXML(t *testing.T) { + feed, err := ParseFeed([]byte(` +