diff --git a/apps/web_v1/src/App.css b/apps/web_v1/src/App.css
index cc7b1f1..4c8fb39 100644
--- a/apps/web_v1/src/App.css
+++ b/apps/web_v1/src/App.css
@@ -965,6 +965,15 @@
margin-bottom: 24px;
}
+.explore-article-image {
+ display: block;
+ width: 100%;
+ max-height: 420px;
+ margin: 0 0 24px;
+ border-radius: 12px;
+ object-fit: cover;
+}
+
.explore-article-content {
margin: 0;
color: var(--senlin-text);
diff --git a/apps/web_v1/src/api/dataset.ts b/apps/web_v1/src/api/dataset.ts
index e264142..89e4e0a 100644
--- a/apps/web_v1/src/api/dataset.ts
+++ b/apps/web_v1/src/api/dataset.ts
@@ -24,6 +24,7 @@ export type DatasetItemDTO = {
summary: string
content: string
url: string
+ imageUrl: string
status: 'unread' | 'read' | 'archived'
starred: boolean
publishedAt: string | null
diff --git a/apps/web_v1/src/pages/workspace-explore.tsx b/apps/web_v1/src/pages/workspace-explore.tsx
index 8caafe4..413c27a 100644
--- a/apps/web_v1/src/pages/workspace-explore.tsx
+++ b/apps/web_v1/src/pages/workspace-explore.tsx
@@ -445,6 +445,9 @@ export function WorkspaceExplorePage({
+ ) : null}
]+src\s*=\s*["']([^"']+)["']`)
)
type FeedItem struct {
@@ -34,6 +35,7 @@ type FeedItem struct {
Summary string
Content string
URL string
+ ImageURL string
PublishedAt *time.Time
}
@@ -156,6 +158,8 @@ type jsonFeedItem struct {
Summary string `json:"summary"`
ContentText string `json:"content_text"`
ContentHTML string `json:"content_html"`
+ Image string `json:"image"`
+ BannerImage string `json:"banner_image"`
DatePublished string `json:"date_published"`
}
@@ -175,7 +179,8 @@ func parseJSONFeed(body []byte) (ParsedFeed, error) {
}
item := normalizeFeedItem(FeedItem{
ExternalID: input.ID, Title: plainText(input.Title), Summary: plainText(input.Summary),
- Content: content, URL: input.URL, PublishedAt: parseFeedTime(input.DatePublished),
+ Content: content, URL: input.URL, ImageURL: firstNonEmpty(input.Image, input.BannerImage),
+ PublishedAt: parseFeedTime(input.DatePublished),
})
if item.Title != "" {
items = append(items, item)
@@ -194,13 +199,16 @@ type xmlFeedDocument struct {
}
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"`
+ 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"`
+ Enclosure xmlMedia `xml:"enclosure"`
+ Thumbnail xmlMedia `xml:"thumbnail"`
+ Media []xmlMedia `xml:"content"`
}
type xmlAtomEntry struct {
@@ -216,6 +224,13 @@ type xmlAtomEntry struct {
type xmlAtomLink struct {
Href string `xml:"href,attr"`
Rel string `xml:"rel,attr"`
+ Type string `xml:"type,attr"`
+}
+
+type xmlMedia struct {
+ URL string `xml:"url,attr"`
+ Type string `xml:"type,attr"`
+ Medium string `xml:"medium,attr"`
}
func parseXMLFeed(body []byte) (ParsedFeed, error) {
@@ -238,7 +253,15 @@ func parseXMLFeed(body []byte) (ParsedFeed, error) {
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)),
+ URL: strings.TrimSpace(input.Link),
+ ImageURL: firstNonEmpty(
+ imageMediaURL(input.Enclosure),
+ imageMediaURL(input.Thumbnail),
+ firstImageMediaURL(input.Media),
+ htmlImageURL(input.Content),
+ htmlImageURL(input.Description),
+ ),
+ PublishedAt: parseFeedTime(firstNonEmpty(input.PubDate, input.Date)),
})
if item.Title != "" {
items = append(items, item)
@@ -251,7 +274,8 @@ func parseXMLFeed(body []byte) (ParsedFeed, error) {
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)),
+ URL: atomLink(input.Links), ImageURL: atomImageURL(input.Links),
+ PublishedAt: parseFeedTime(firstNonEmpty(input.Published, input.Updated)),
})
if item.Title != "" {
items = append(items, item)
@@ -268,6 +292,7 @@ func normalizeFeedItem(item FeedItem) FeedItem {
item.Summary = strings.TrimSpace(item.Summary)
item.Content = strings.TrimSpace(item.Content)
item.URL = truncateRunes(strings.TrimSpace(item.URL), 2048)
+ item.ImageURL = truncateRunes(strings.TrimSpace(item.ImageURL), 2048)
key := firstNonEmpty(strings.TrimSpace(item.ExternalID), item.URL)
if key == "" {
published := ""
@@ -305,6 +330,32 @@ func plainText(value string) string {
return strings.Join(strings.Fields(html.UnescapeString(value)), " ")
}
+func htmlImageURL(value string) string {
+ match := htmlImagePattern.FindStringSubmatch(value)
+ if len(match) < 2 {
+ return ""
+ }
+ return strings.TrimSpace(html.UnescapeString(match[1]))
+}
+
+func imageMediaURL(media xmlMedia) string {
+ mediaType := strings.ToLower(strings.TrimSpace(media.Type))
+ medium := strings.ToLower(strings.TrimSpace(media.Medium))
+ if media.URL == "" || (mediaType != "" && !strings.HasPrefix(mediaType, "image/") && medium != "image") {
+ return ""
+ }
+ return strings.TrimSpace(media.URL)
+}
+
+func firstImageMediaURL(media []xmlMedia) string {
+ for _, item := range media {
+ if imageURL := imageMediaURL(item); imageURL != "" {
+ return imageURL
+ }
+ }
+ return ""
+}
+
func atomLink(links []xmlAtomLink) string {
for _, link := range links {
if link.Rel == "" || link.Rel == "alternate" {
@@ -317,6 +368,16 @@ func atomLink(links []xmlAtomLink) string {
return ""
}
+func atomImageURL(links []xmlAtomLink) string {
+ for _, link := range links {
+ if strings.EqualFold(link.Rel, "enclosure") &&
+ strings.HasPrefix(strings.ToLower(strings.TrimSpace(link.Type)), "image/") {
+ return strings.TrimSpace(link.Href)
+ }
+ }
+ return ""
+}
+
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
diff --git a/backend/internal/logic/dataset/feed_test.go b/backend/internal/logic/dataset/feed_test.go
index e7e090b..10b13e2 100644
--- a/backend/internal/logic/dataset/feed_test.go
+++ b/backend/internal/logic/dataset/feed_test.go
@@ -21,6 +21,7 @@ func TestParseFeedDetectsJSONFeedFromContent(t *testing.T) {
"title": "JSON item",
"summary": "JSON summary",
"content_html": "
JSON content
", + "image": "https://example.com/json-image.jpg", "date_published": "2026-07-23T08:00:00Z" }] }`), "text/plain") @@ -31,6 +32,7 @@ func TestParseFeedDetectsJSONFeedFromContent(t *testing.T) { 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.Equal(t, "https://example.com/json-image.jpg", feed.Items[0].ImageURL) require.NotEmpty(t, feed.Items[0].ExternalID) require.NotNil(t, feed.Items[0].PublishedAt) } @@ -45,6 +47,7 @@ func TestParseFeedDetectsRSSXML(t *testing.T) {