feat: collect JSON and XML feeds
This commit is contained in:
@@ -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(),
|
||||
})),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
347
backend/internal/logic/dataset/feed.go
Normal file
347
backend/internal/logic/dataset/feed.go
Normal file
@@ -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()
|
||||
}
|
||||
126
backend/internal/logic/dataset/feed_test.go
Normal file
126
backend/internal/logic/dataset/feed_test.go
Normal file
@@ -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": "<p>JSON <strong>content</strong></p>",
|
||||
"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(`<?xml version="1.0"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<item>
|
||||
<title>RSS item</title>
|
||||
<link>https://example.com/rss-1</link>
|
||||
<guid>rss-1</guid>
|
||||
<description><![CDATA[<p>RSS summary</p>]]></description>
|
||||
<content:encoded xmlns:content="http://purl.org/rss/1.0/modules/content/"><![CDATA[<p>RSS content</p>]]></content:encoded>
|
||||
<pubDate>Thu, 23 Jul 2026 08:00:00 +0000</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>`), "application/xml")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "rss", feed.Format)
|
||||
require.Len(t, feed.Items, 1)
|
||||
require.Equal(t, "RSS summary", feed.Items[0].Summary)
|
||||
require.Equal(t, "RSS content", feed.Items[0].Content)
|
||||
require.NotNil(t, feed.Items[0].PublishedAt)
|
||||
}
|
||||
|
||||
func TestParseFeedDetectsAtomXML(t *testing.T) {
|
||||
feed, err := ParseFeed([]byte(`<?xml version="1.0"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<entry>
|
||||
<title>Atom item</title>
|
||||
<id>atom-1</id>
|
||||
<link rel="alternate" href="https://example.com/atom-1"/>
|
||||
<summary>Atom summary</summary>
|
||||
<content>Atom content</content>
|
||||
<updated>2026-07-23T08:00:00Z</updated>
|
||||
</entry>
|
||||
</feed>`), "application/atom+xml")
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "atom", feed.Format)
|
||||
require.Len(t, feed.Items, 1)
|
||||
require.Equal(t, "https://example.com/atom-1", feed.Items[0].URL)
|
||||
require.Equal(t, "Atom content", feed.Items[0].Content)
|
||||
}
|
||||
|
||||
func TestParseFeedRejectsUnknownContent(t *testing.T) {
|
||||
_, err := ParseFeed([]byte("not a feed"), "text/plain")
|
||||
require.ErrorIs(t, err, ErrFeedFormat)
|
||||
}
|
||||
|
||||
func TestValidateFeedURLRejectsLocalAddressesAndCredentials(t *testing.T) {
|
||||
for _, value := range []string{
|
||||
"http://127.0.0.1/feed",
|
||||
"http://[::1]/feed",
|
||||
"http://169.254.169.254/latest/meta-data",
|
||||
"https://user:password@example.com/feed",
|
||||
} {
|
||||
parsed, err := url.Parse(value)
|
||||
require.NoError(t, err)
|
||||
require.Error(t, validateFeedURL(parsed), value)
|
||||
}
|
||||
|
||||
parsed, err := url.Parse("https://rsshub.app/cls/hot")
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, validateFeedURL(parsed))
|
||||
}
|
||||
|
||||
func TestSyncSourcesRecordsFeedFailure(t *testing.T) {
|
||||
database := newDatasetTestDatabase(t)
|
||||
user := createDatasetTestUser(t, database, "feed-owner@example.com")
|
||||
source := models.SaDatasetSource{
|
||||
CreatedBy: user.ID,
|
||||
Name: "Broken feed",
|
||||
Kind: "rss",
|
||||
URL: "https://example.com/feed",
|
||||
Enabled: true,
|
||||
}
|
||||
require.NoError(t, database.Create(&source).Error)
|
||||
|
||||
crons, err := newServiceWithFetcher(database, failingFeedFetcher{}).SyncSources(context.Background(), user.ID)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, crons, 1)
|
||||
require.Equal(t, "failed", crons[0].Status)
|
||||
require.False(t, crons[0].Enabled)
|
||||
require.Contains(t, crons[0].LastResult, "feed unavailable")
|
||||
}
|
||||
|
||||
type failingFeedFetcher struct{}
|
||||
|
||||
func (failingFeedFetcher) Fetch(context.Context, string) (ParsedFeed, error) {
|
||||
return ParsedFeed{}, errors.New("feed unavailable")
|
||||
}
|
||||
@@ -272,7 +272,7 @@ func (h *Handler) queueSync(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
crons, err := h.service.QueueSync(userID)
|
||||
crons, err := h.service.SyncSources(c.Request.Context(), userID)
|
||||
if err != nil {
|
||||
writeError(c, err)
|
||||
return
|
||||
@@ -281,7 +281,7 @@ func (h *Handler) queueSync(c *gin.Context) {
|
||||
for _, cron := range crons {
|
||||
result = append(result, cronDTO(cron))
|
||||
}
|
||||
c.JSON(http.StatusAccepted, result)
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
func currentUser(c *gin.Context) (uint, bool) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package dataset
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -63,17 +64,21 @@ func TestDatasetAPIConnectsSourcesItemsAndSyncTasks(t *testing.T) {
|
||||
require.True(t, item.Starred)
|
||||
|
||||
firstSync := performDatasetRequest(t, ownerRouter, http.MethodPost, "/api/v1/dataset-crons/sync", map[string]any{})
|
||||
require.Equal(t, http.StatusAccepted, firstSync.Code)
|
||||
require.Equal(t, http.StatusOK, firstSync.Code)
|
||||
var firstCrons []CronDTO
|
||||
require.NoError(t, json.Unmarshal(firstSync.Body.Bytes(), &firstCrons))
|
||||
require.Len(t, firstCrons, 1)
|
||||
require.Equal(t, "pending", firstCrons[0].Status)
|
||||
require.Equal(t, "completed", firstCrons[0].Status)
|
||||
require.Contains(t, firstCrons[0].LastResult, "format=rss")
|
||||
|
||||
secondSync := performDatasetRequest(t, ownerRouter, http.MethodPost, "/api/v1/dataset-crons/sync", map[string]any{})
|
||||
require.Equal(t, http.StatusAccepted, secondSync.Code)
|
||||
require.Equal(t, http.StatusOK, secondSync.Code)
|
||||
var secondCrons []CronDTO
|
||||
require.NoError(t, json.Unmarshal(secondSync.Body.Bytes(), &secondCrons))
|
||||
require.Equal(t, firstCrons[0].ID, secondCrons[0].ID)
|
||||
require.NotEqual(t, firstCrons[0].ID, secondCrons[0].ID)
|
||||
var collectedItems []models.SaDatasetItem
|
||||
require.NoError(t, database.Where("source_id = ?", 1).Find(&collectedItems).Error)
|
||||
require.Len(t, collectedItems, 2)
|
||||
|
||||
otherRouter := datasetTestRouter(database, other.ID)
|
||||
otherSources := performDatasetRequest(t, otherRouter, http.MethodGet, "/api/v1/dataset-sources", nil)
|
||||
@@ -117,10 +122,25 @@ func datasetTestRouter(database *gorm.DB, userID uint) http.Handler {
|
||||
return httpx.NewProtectedRouter(
|
||||
config.Config{Env: "test"},
|
||||
func(string) (uint, error) { return userID, nil },
|
||||
NewHandler(NewService(database)),
|
||||
NewHandler(newServiceWithFetcher(database, staticFeedFetcher{})),
|
||||
)
|
||||
}
|
||||
|
||||
type staticFeedFetcher struct{}
|
||||
|
||||
func (staticFeedFetcher) Fetch(context.Context, string) (ParsedFeed, error) {
|
||||
return ParsedFeed{
|
||||
Format: "rss",
|
||||
Items: []FeedItem{{
|
||||
ExternalID: "stable-feed-item",
|
||||
Title: "Collected item",
|
||||
Summary: "Collected summary",
|
||||
Content: "Collected content",
|
||||
URL: "https://example.com/collected",
|
||||
}},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func performDatasetRequest(t *testing.T, router http.Handler, method, path string, body any) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
var encoded []byte
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
package dataset
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -20,6 +22,7 @@ var (
|
||||
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
fetcher FeedFetcher
|
||||
}
|
||||
|
||||
type SourceInput struct {
|
||||
@@ -51,7 +54,11 @@ type SourceRecord struct {
|
||||
}
|
||||
|
||||
func NewService(database *gorm.DB) *Service {
|
||||
return &Service{db: database}
|
||||
return &Service{db: database, fetcher: NewHTTPFeedFetcher()}
|
||||
}
|
||||
|
||||
func newServiceWithFetcher(database *gorm.DB, fetcher FeedFetcher) *Service {
|
||||
return &Service{db: database, fetcher: fetcher}
|
||||
}
|
||||
|
||||
func (s *Service) ListSources(userID uint) ([]SourceRecord, error) {
|
||||
@@ -208,36 +215,111 @@ func (s *Service) ListCrons(userID uint) ([]models.SaDatasetCron, error) {
|
||||
return crons, nil
|
||||
}
|
||||
|
||||
func (s *Service) QueueSync(userID uint) ([]models.SaDatasetCron, error) {
|
||||
func (s *Service) SyncSources(ctx context.Context, userID uint) ([]models.SaDatasetCron, error) {
|
||||
result := make([]models.SaDatasetCron, 0)
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
var sources []models.SaDatasetSource
|
||||
if err := tx.Where("created_by = ? AND enabled = ?", userID, true).Order("id asc").Find(&sources).Error; err != nil {
|
||||
return err
|
||||
if err := s.db.Where("created_by = ? AND enabled = ? AND kind = ?", userID, true, "rss").
|
||||
Order("id asc").Find(&sources).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
for _, source := range sources {
|
||||
var cron models.SaDatasetCron
|
||||
err := tx.Where("source_id = ? AND created_by = ? AND status = ?", source.ID, userID, "pending").First(&cron).Error
|
||||
if err == nil {
|
||||
now := time.Now().UTC()
|
||||
cron := models.SaDatasetCron{
|
||||
SourceID: source.ID, CreatedBy: userID, Schedule: "@once",
|
||||
Status: "running", Enabled: true, NextRunAt: &now, LastRunAt: &now,
|
||||
}
|
||||
if err := s.db.Create(&cron).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
feed, fetchErr := s.fetcher.Fetch(ctx, source.URL)
|
||||
if fetchErr != nil {
|
||||
cron.Status = "failed"
|
||||
cron.Enabled = false
|
||||
cron.NextRunAt = nil
|
||||
cron.LastResult = truncateResult(fetchErr.Error())
|
||||
if err := s.db.Model(&cron).Updates(map[string]any{
|
||||
"status": cron.Status, "last_result": cron.LastResult, "enabled": false, "next_run_at": nil,
|
||||
}).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result = append(result, cron)
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return err
|
||||
}
|
||||
cron = models.SaDatasetCron{
|
||||
SourceID: source.ID, CreatedBy: userID, Schedule: "@once",
|
||||
Status: "pending", Enabled: true, NextRunAt: &now,
|
||||
}
|
||||
if err := tx.Create(&cron).Error; err != nil {
|
||||
return err
|
||||
|
||||
inserted, err := s.storeFeedItems(userID, source, feed.Items)
|
||||
if err != nil {
|
||||
cron.Status = "failed"
|
||||
cron.Enabled = false
|
||||
cron.NextRunAt = nil
|
||||
cron.LastResult = truncateResult("store feed items: " + err.Error())
|
||||
if updateErr := s.db.Model(&cron).Updates(map[string]any{
|
||||
"status": cron.Status, "last_result": cron.LastResult, "enabled": false, "next_run_at": nil,
|
||||
}).Error; updateErr != nil {
|
||||
return nil, updateErr
|
||||
}
|
||||
result = append(result, cron)
|
||||
continue
|
||||
}
|
||||
completedAt := time.Now().UTC()
|
||||
cron.Status = "completed"
|
||||
cron.Enabled = false
|
||||
cron.NextRunAt = nil
|
||||
cron.LastResult = fmt.Sprintf("format=%s fetched=%d inserted=%d", feed.Format, len(feed.Items), inserted)
|
||||
if err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
if err := tx.Model(&cron).Updates(map[string]any{
|
||||
"status": cron.Status, "last_result": cron.LastResult, "enabled": false, "next_run_at": nil,
|
||||
}).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Model(&source).Update("last_synced_at", completedAt).Error
|
||||
}); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
source.LastSyncedAt = &completedAt
|
||||
result = append(result, cron)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) storeFeedItems(userID uint, source models.SaDatasetSource, items []FeedItem) (int, error) {
|
||||
inserted := 0
|
||||
err := s.db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, input := range items {
|
||||
var count int64
|
||||
if err := tx.Model(&models.SaDatasetItem{}).
|
||||
Where("source_id = ? AND external_id = ?", source.ID, input.ExternalID).
|
||||
Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
continue
|
||||
}
|
||||
item := models.SaDatasetItem{
|
||||
SourceID: source.ID, CreatedBy: userID, ExternalID: input.ExternalID,
|
||||
Title: input.Title, Summary: input.Summary, Content: input.Content,
|
||||
URL: input.URL, Status: "unread", PublishedAt: input.PublishedAt,
|
||||
}
|
||||
if err := tx.Create(&item).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
inserted++
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return result, err
|
||||
return inserted, err
|
||||
}
|
||||
|
||||
func truncateResult(value string) string {
|
||||
return truncateRunes(value, 2000)
|
||||
}
|
||||
|
||||
func truncateRunes(value string, limit int) string {
|
||||
runes := []rune(value)
|
||||
if len(runes) <= limit {
|
||||
return value
|
||||
}
|
||||
return string(runes[:limit])
|
||||
}
|
||||
|
||||
func (s *Service) findOwnedSource(userID uint, identity string) (*models.SaDatasetSource, error) {
|
||||
@@ -272,7 +354,8 @@ func normalizeSourceInput(input SourceInput) (SourceInput, error) {
|
||||
|
||||
func validHTTPURL(value string) bool {
|
||||
parsed, err := url.ParseRequestURI(value)
|
||||
return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") && parsed.Host != ""
|
||||
return err == nil && (parsed.Scheme == "http" || parsed.Scheme == "https") &&
|
||||
parsed.Host != "" && parsed.User == nil
|
||||
}
|
||||
|
||||
func utcOptionalTime(value *time.Time) *time.Time {
|
||||
|
||||
@@ -9,6 +9,7 @@ type SaDatasetItem struct {
|
||||
SourceIdentity string `gorm:"type:char(36);index"`
|
||||
CreatedBy uint `gorm:"index;not null"`
|
||||
CreatedByIdentity string `gorm:"type:char(36);index"`
|
||||
ExternalID string `gorm:"size:64;index"`
|
||||
Title string `gorm:"size:500;not null"`
|
||||
Summary string `gorm:"type:text"`
|
||||
Content string `gorm:"type:text"`
|
||||
|
||||
Reference in New Issue
Block a user