diff --git a/atom/parser.go b/atom/parser.go
index bd9cd640..c9fad0b2 100644
--- a/atom/parser.go
+++ b/atom/parser.go
@@ -109,7 +109,9 @@ func (ap *Parser) parseRoot(p *xpp.Parser) (*Feed, error) {
atom.Entries = append(atom.Entries, entry)
}
default:
- err = p.Skip()
+ // Not part of the spec: capture it into the extension map
+ // under the _custom pseudo namespace instead of dropping it.
+ extensions, _, err = shared.ParseCustom(extensions, p)
}
return err
})
@@ -215,7 +217,9 @@ func (ap *Parser) parseEntry(p *xpp.Parser) (*Entry, error) {
case "content":
entry.Content, err = ap.parseContent(p)
default:
- err = p.Skip()
+ // Not part of the spec: capture it into the extension map
+ // under the _custom pseudo namespace instead of dropping it.
+ extensions, _, err = shared.ParseCustom(extensions, p)
}
return err
})
@@ -310,7 +314,9 @@ func (ap *Parser) parseSource(p *xpp.Parser) (*Source, error) {
categories = append(categories, cat)
}
default:
- err = p.Skip()
+ // Not part of the spec: capture it into the extension map
+ // under the _custom pseudo namespace instead of dropping it.
+ extensions, _, err = shared.ParseCustom(extensions, p)
}
return err
})
diff --git a/extension_helpers.go b/extension_helpers.go
new file mode 100644
index 00000000..f911d98c
--- /dev/null
+++ b/extension_helpers.go
@@ -0,0 +1,71 @@
+package gofeed
+
+import (
+ ext "github.com/mmcdole/gofeed/extensions"
+)
+
+// CustomNamespace is the pseudo namespace under which non-namespaced unknown
+// elements are filed in Extensions, preserving nesting, attributes and
+// repetition.
+const CustomNamespace = "_custom"
+
+// GetExtension returns the extension elements for the given namespace prefix
+// and element name, or nil when absent. Non-namespaced custom elements live
+// under CustomNamespace.
+func (f *Feed) GetExtension(namespace, element string) []ext.Extension {
+ return extensionsFor(f.Extensions, namespace, element)
+}
+
+// GetExtension returns the extension elements for the given namespace prefix
+// and element name, or nil when absent. Non-namespaced custom elements live
+// under CustomNamespace.
+func (i *Item) GetExtension(namespace, element string) []ext.Extension {
+ return extensionsFor(i.Extensions, namespace, element)
+}
+
+// GetExtensionValue returns the text of the first matching extension
+// element, or "" when absent.
+func (f *Feed) GetExtensionValue(namespace, element string) string {
+ return firstExtensionValue(f.Extensions, namespace, element)
+}
+
+// GetExtensionValue returns the text of the first matching extension
+// element, or "" when absent.
+func (i *Item) GetExtensionValue(namespace, element string) string {
+ return firstExtensionValue(i.Extensions, namespace, element)
+}
+
+// GetCustomValue returns the text of the first non-namespaced custom element
+// with the given name, or "" when absent. Unlike the flat Custom map, the
+// backing extension tree also holds nested elements, attributes and repeated
+// values; use GetExtension(CustomNamespace, element) for those.
+func (f *Feed) GetCustomValue(element string) string {
+ return firstExtensionValue(f.Extensions, CustomNamespace, element)
+}
+
+// GetCustomValue returns the text of the first non-namespaced custom element
+// with the given name, or "" when absent. Unlike the flat Custom map, the
+// backing extension tree also holds nested elements, attributes and repeated
+// values; use GetExtension(CustomNamespace, element) for those.
+func (i *Item) GetCustomValue(element string) string {
+ return firstExtensionValue(i.Extensions, CustomNamespace, element)
+}
+
+func extensionsFor(exts ext.Extensions, namespace, element string) []ext.Extension {
+ if exts == nil {
+ return nil
+ }
+ nsMap, ok := exts[namespace]
+ if !ok {
+ return nil
+ }
+ return nsMap[element]
+}
+
+func firstExtensionValue(exts ext.Extensions, namespace, element string) string {
+ matches := extensionsFor(exts, namespace, element)
+ if len(matches) == 0 {
+ return ""
+ }
+ return matches[0].Value
+}
diff --git a/extension_helpers_test.go b/extension_helpers_test.go
new file mode 100644
index 00000000..f6ad2bcf
--- /dev/null
+++ b/extension_helpers_test.go
@@ -0,0 +1,56 @@
+package gofeed_test
+
+import (
+ "strings"
+ "testing"
+
+ "github.com/mmcdole/gofeed"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestExtensionHelpers(t *testing.T) {
+ feed := `
+
+ feed-1
+ -
+ Jane
+ Hall
+ plain
+ again
+
+
+ `
+
+ f, err := gofeed.NewParser().Parse(strings.NewReader(feed))
+ assert.NoError(t, err)
+ item := f.Items[0]
+
+ // Namespaced extensions through the same accessors.
+ assert.Equal(t, "Jane", item.GetExtensionValue("dc", "creator"))
+ assert.Len(t, item.GetExtension("dc", "creator"), 1)
+
+ // Feed level custom element, previously dropped entirely.
+ assert.Equal(t, "feed-1", f.GetCustomValue("customFeedId"))
+
+ // Item level custom values, including repetition the flat map loses.
+ assert.Equal(t, "plain", item.GetCustomValue("simple"))
+ assert.Len(t, item.GetExtension(gofeed.CustomNamespace, "simple"), 2)
+
+ // Nested custom elements keep children and attributes in the tree.
+ events := item.GetExtension(gofeed.CustomNamespace, "event")
+ if assert.Len(t, events, 1) {
+ venue := events[0].Children["venue"][0]
+ assert.Equal(t, "Hall", venue.Value)
+ assert.Equal(t, "Austin", venue.Attrs["city"])
+ }
+
+ // The flat Custom map still works for childless elements (last wins),
+ // and nested elements no longer corrupt it.
+ assert.Equal(t, "again", item.Custom["simple"])
+ _, hasEvent := item.Custom["event"]
+ assert.False(t, hasEvent)
+
+ // Absent lookups are empty, not panics.
+ assert.Nil(t, f.GetExtension("nope", "x"))
+ assert.Equal(t, "", item.GetCustomValue("absent"))
+}
diff --git a/feed.go b/feed.go
index c63c8d8b..f5530b03 100644
--- a/feed.go
+++ b/feed.go
@@ -32,10 +32,13 @@ type Feed struct {
DublinCoreExt *ext.DublinCoreExtension `json:"dcExt,omitempty"`
ITunesExt *ext.ITunesFeedExtension `json:"itunesExt,omitempty"`
Extensions ext.Extensions `json:"extensions,omitempty"`
- Custom map[string]string `json:"custom,omitempty"`
- Items []*Item `json:"items"`
- FeedType string `json:"feedType"`
- FeedVersion string `json:"feedVersion"`
+ // Custom is a flat view of non-namespaced unknown elements. Deprecated:
+ // use Extensions[CustomNamespace] (or GetCustomValue), which keeps
+ // nesting, attributes and repeated elements.
+ Custom map[string]string `json:"custom,omitempty"`
+ Items []*Item `json:"items"`
+ FeedType string `json:"feedType"`
+ FeedVersion string `json:"feedVersion"`
// originalFeed holds the source *rss.Feed, *atom.Feed, or *json.Feed when
// the parser was configured with KeepOriginalFeed. It is unexported (and so
@@ -81,7 +84,11 @@ type Item struct {
DublinCoreExt *ext.DublinCoreExtension `json:"dcExt,omitempty"`
ITunesExt *ext.ITunesItemExtension `json:"itunesExt,omitempty"`
Extensions ext.Extensions `json:"extensions,omitempty"`
- Custom map[string]string `json:"custom,omitempty"`
+ // Custom is a flat view of childless non-namespaced unknown elements,
+ // last value wins. Deprecated: use Extensions[CustomNamespace] (or
+ // GetCustomValue), which keeps nesting, attributes and repeated
+ // elements.
+ Custom map[string]string `json:"custom,omitempty"`
}
// Person is an individual specified in a feed
diff --git a/internal/shared/extparser.go b/internal/shared/extparser.go
index 3005c3fc..b65dfca9 100644
--- a/internal/shared/extparser.go
+++ b/internal/shared/extparser.go
@@ -175,3 +175,25 @@ var canonicalNamespaces = map[string]string{
"http://www.w3.org/XML/1998/namespace": "xml",
"http://podlove.org/simple-chapters": "psc",
}
+
+// CustomPrefix is the pseudo namespace prefix that files non-namespaced
+// unknown elements in the extension map.
+const CustomPrefix = "_custom"
+
+// ParseCustom parses the current element as an arbitrary non-namespaced
+// element and files it in the extension map under CustomPrefix, preserving
+// nesting, attributes and repetition the same way namespaced extensions are
+// kept. The parsed element is also returned so callers can maintain
+// compatibility shims from it.
+func ParseCustom(fe ext.Extensions, p *xpp.Parser) (ext.Extensions, ext.Extension, error) {
+ result, err := parseExtensionElement(p)
+ if err != nil {
+ return nil, ext.Extension{}, err
+ }
+
+ if _, ok := fe[CustomPrefix]; !ok {
+ fe[CustomPrefix] = map[string][]ext.Extension{}
+ }
+ fe[CustomPrefix][result.Name] = append(fe[CustomPrefix][result.Name], result)
+ return fe, result, nil
+}
diff --git a/internal/shared/extparser_test.go b/internal/shared/extparser_test.go
index c8ce0534..2a2a0652 100644
--- a/internal/shared/extparser_test.go
+++ b/internal/shared/extparser_test.go
@@ -156,3 +156,67 @@ func TestNewXMLParserLeniencyAndCharset(t *testing.T) {
t.Errorf("text = %q, want café", text)
}
}
+
+func TestParseCustom(t *testing.T) {
+ doc := `
+
+ -
+ Hall
+ plain
+ again
+
+
+ `
+
+ p := NewXMLParser(strings.NewReader(doc))
+ extensions := ext.Extensions{}
+ var parsed []ext.Extension
+ for {
+ tok, err := p.NextToken()
+ if err != nil {
+ t.Fatal(err)
+ }
+ if tok == xpp.EndDocument {
+ break
+ }
+ if tok == xpp.StartTag {
+ switch p.Name() {
+ case "event", "simple":
+ var e ext.Extension
+ var err error
+ extensions, e, err = ParseCustom(extensions, p)
+ if err != nil {
+ t.Fatal(err)
+ }
+ parsed = append(parsed, e)
+ }
+ }
+ }
+
+ if len(parsed) != 3 {
+ t.Fatalf("parsed %d elements, want 3", len(parsed))
+ }
+
+ // Nesting and attributes survive in the tree.
+ events := extensions[CustomPrefix]["event"]
+ if len(events) != 1 {
+ t.Fatalf("event entries = %d, want 1", len(events))
+ }
+ venue := events[0].Children["venue"][0]
+ if venue.Value != "Hall" || venue.Attrs["city"] != "Austin" {
+ t.Fatalf("venue = %+v", venue)
+ }
+
+ // Repetition appends rather than overwrites.
+ if n := len(extensions[CustomPrefix]["simple"]); n != 2 {
+ t.Fatalf("simple entries = %d, want 2", n)
+ }
+
+ // The returned element mirrors what was filed.
+ if parsed[0].Name != "event" || len(parsed[0].Children) != 1 {
+ t.Fatalf("returned element = %+v", parsed[0])
+ }
+ if parsed[1].Value != "plain" || len(parsed[1].Children) != 0 {
+ t.Fatalf("returned simple = %+v", parsed[1])
+ }
+}
diff --git a/rss/parser.go b/rss/parser.go
index 0fb7e9d8..78c518a8 100644
--- a/rss/parser.go
+++ b/rss/parser.go
@@ -178,10 +178,14 @@ func (rp *Parser) parseChannel(p *xpp.Parser) (rss *Feed, err error) {
rss.Image, err = rp.parseImage(p)
case "textinput":
rss.TextInput, err = rp.parseTextInput(p)
- default:
- // Skip element as it isn't an extension and not part of
- // the spec.
+ case "items":
+ // The RSS 1.0 rdf:Seq is a structural list of item
+ // references, not content; skip it rather than capture it.
err = p.Skip()
+ default:
+ // Not part of the spec: capture it into the extension map
+ // under the _custom pseudo namespace instead of dropping it.
+ extensions, _, err = shared.ParseCustom(extensions, p)
}
return err
})
@@ -227,6 +231,26 @@ func (rp *Parser) parseItem(p *xpp.Parser) (item *Item, err error) {
enclosures := []*Enclosure{}
links := []string{}
+ // captureCustom files an unrecognized child in the extension map under
+ // the _custom pseudo namespace, preserving nesting and attributes, and
+ // keeps the flat Custom map populated for elements without children so
+ // existing Custom reads are unchanged.
+ captureCustom := func(p *xpp.Parser) error {
+ var e ext.Extension
+ var err error
+ extensions, e, err = shared.ParseCustom(extensions, p)
+ if err != nil {
+ return err
+ }
+ if len(e.Children) == 0 {
+ if item.Custom == nil {
+ item.Custom = make(map[string]string)
+ }
+ item.Custom[e.Name] = shared.DecodeEntities(e.Value)
+ }
+ return nil
+ }
+
err = shared.ForEachChild(p, func(name string) error {
if shared.IsExtension(p) {
extensions, err = shared.ParseExtension(extensions, p)
@@ -242,7 +266,7 @@ func (rp *Parser) parseItem(p *xpp.Parser) (item *Item, err error) {
if shared.PrefixForNamespace(p.Space(), p) == "content" {
item.Content, err = shared.ParseText(p)
} else {
- err = rp.parseItemCustom(p, item)
+ err = captureCustom(p)
}
case "link":
if item.Link, err = rp.parseLink(p); err == nil {
@@ -275,7 +299,7 @@ func (rp *Parser) parseItem(p *xpp.Parser) (item *Item, err error) {
categories = append(categories, cat)
}
default:
- err = rp.parseItemCustom(p, item)
+ err = captureCustom(p)
}
return err
})
@@ -314,21 +338,6 @@ func (rp *Parser) parseItem(p *xpp.Parser) (item *Item, err error) {
return item, nil
}
-// parseItemCustom stores an unrecognized item child in the Custom map,
-// keyed by its original-case name. Duplicate names keep the last value.
-func (rp *Parser) parseItemCustom(p *xpp.Parser, item *Item) error {
- key := p.Name()
- result, err := shared.ParseText(p)
- if err != nil {
- return err
- }
- if item.Custom == nil {
- item.Custom = make(map[string]string)
- }
- item.Custom[key] = result
- return nil
-}
-
func (rp *Parser) parseLink(p *xpp.Parser) (url string, err error) {
base := p.BaseURL()
href := p.Attribute("href")
diff --git a/testdata/parser/atom/atom10_feed_custom_elements.json b/testdata/parser/atom/atom10_feed_custom_elements.json
new file mode 100644
index 00000000..61f5374f
--- /dev/null
+++ b/testdata/parser/atom/atom10_feed_custom_elements.json
@@ -0,0 +1,40 @@
+{
+ "title": "Test Feed",
+ "id": "urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6",
+ "updated": "2003-12-13T18:30:02Z",
+ "updatedParsed": "2003-12-13T18:30:02Z",
+ "entries": [],
+ "extensions": {
+ "_custom": {
+ "customFeedId": [
+ {
+ "name": "customFeedId",
+ "value": "feed-123",
+ "attrs": {},
+ "children": {}
+ }
+ ],
+ "customMetadata": [
+ {
+ "name": "customMetadata",
+ "value": "Some metadata",
+ "attrs": {
+ "type": "internal"
+ },
+ "children": {}
+ }
+ ],
+ "updateInterval": [
+ {
+ "name": "updateInterval",
+ "value": "30",
+ "attrs": {
+ "units": "minutes"
+ },
+ "children": {}
+ }
+ ]
+ }
+ },
+ "version": "1.0"
+}
diff --git a/testdata/parser/atom/atom10_feed_custom_elements.xml b/testdata/parser/atom/atom10_feed_custom_elements.xml
new file mode 100644
index 00000000..93606959
--- /dev/null
+++ b/testdata/parser/atom/atom10_feed_custom_elements.xml
@@ -0,0 +1,12 @@
+
+
+
+ Test Feed
+ urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6
+ 2003-12-13T18:30:02Z
+ feed-123
+ 30
+ Some metadata
+
\ No newline at end of file
diff --git a/testdata/parser/atom/atom10_feed_entry_custom_elements.json b/testdata/parser/atom/atom10_feed_entry_custom_elements.json
new file mode 100644
index 00000000..3112f1d1
--- /dev/null
+++ b/testdata/parser/atom/atom10_feed_entry_custom_elements.json
@@ -0,0 +1,45 @@
+{
+ "title": "Test Feed",
+ "id": "urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6",
+ "updated": "2003-12-13T18:30:02Z",
+ "updatedParsed": "2003-12-13T18:30:02Z",
+ "entries": [
+ {
+ "title": "Test Entry",
+ "id": "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a",
+ "updated": "2003-12-13T18:30:02Z",
+ "updatedParsed": "2003-12-13T18:30:02Z",
+ "extensions": {
+ "_custom": {
+ "customId": [
+ {
+ "name": "customId",
+ "value": "entry-123",
+ "attrs": {},
+ "children": {}
+ }
+ ],
+ "customTag": [
+ {
+ "name": "customTag",
+ "value": "Custom Value",
+ "attrs": {},
+ "children": {}
+ }
+ ],
+ "priority": [
+ {
+ "name": "priority",
+ "value": "1",
+ "attrs": {
+ "level": "high"
+ },
+ "children": {}
+ }
+ ]
+ }
+ }
+ }
+ ],
+ "version": "1.0"
+}
diff --git a/testdata/parser/atom/atom10_feed_entry_custom_elements.xml b/testdata/parser/atom/atom10_feed_entry_custom_elements.xml
new file mode 100644
index 00000000..41fe6d81
--- /dev/null
+++ b/testdata/parser/atom/atom10_feed_entry_custom_elements.xml
@@ -0,0 +1,18 @@
+
+
+
+ Test Feed
+ urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6
+ 2003-12-13T18:30:02Z
+
+
+ Test Entry
+ urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a
+ 2003-12-13T18:30:02Z
+ entry-123
+ 1
+ Custom Value
+
+
\ No newline at end of file
diff --git a/testdata/parser/atom/atom10_feed_entry_custom_multiple.json b/testdata/parser/atom/atom10_feed_entry_custom_multiple.json
new file mode 100644
index 00000000..87abb485
--- /dev/null
+++ b/testdata/parser/atom/atom10_feed_entry_custom_multiple.json
@@ -0,0 +1,57 @@
+{
+ "title": "Test Feed",
+ "id": "urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6",
+ "updated": "2003-12-13T18:30:02Z",
+ "updatedParsed": "2003-12-13T18:30:02Z",
+ "entries": [
+ {
+ "title": "Test Entry",
+ "id": "urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a",
+ "updated": "2003-12-13T18:30:02Z",
+ "updatedParsed": "2003-12-13T18:30:02Z",
+ "extensions": {
+ "_custom": {
+ "keyword": [
+ {
+ "name": "keyword",
+ "value": "atom",
+ "attrs": {
+ "type": "primary"
+ },
+ "children": {}
+ },
+ {
+ "name": "keyword",
+ "value": "feed",
+ "attrs": {
+ "type": "secondary"
+ },
+ "children": {}
+ }
+ ],
+ "tag": [
+ {
+ "name": "tag",
+ "value": "First Tag",
+ "attrs": {},
+ "children": {}
+ },
+ {
+ "name": "tag",
+ "value": "Second Tag",
+ "attrs": {},
+ "children": {}
+ },
+ {
+ "name": "tag",
+ "value": "Third Tag",
+ "attrs": {},
+ "children": {}
+ }
+ ]
+ }
+ }
+ }
+ ],
+ "version": "1.0"
+}
diff --git a/testdata/parser/atom/atom10_feed_entry_custom_multiple.xml b/testdata/parser/atom/atom10_feed_entry_custom_multiple.xml
new file mode 100644
index 00000000..7f5aa636
--- /dev/null
+++ b/testdata/parser/atom/atom10_feed_entry_custom_multiple.xml
@@ -0,0 +1,20 @@
+
+
+
+ Test Feed
+ urn:uuid:60a76c80-d399-11d9-b93C-0003939e0af6
+ 2003-12-13T18:30:02Z
+
+
+ Test Entry
+ urn:uuid:1225c695-cfb8-4ebb-aaaa-80da344efa6a
+ 2003-12-13T18:30:02Z
+ First Tag
+ Second Tag
+ Third Tag
+ atom
+ feed
+
+
\ No newline at end of file
diff --git a/testdata/parser/atom/issue_275_extensions_and_source.json b/testdata/parser/atom/issue_275_extensions_and_source.json
index 40c828b1..1793ef2e 100644
--- a/testdata/parser/atom/issue_275_extensions_and_source.json
+++ b/testdata/parser/atom/issue_275_extensions_and_source.json
@@ -19,6 +19,16 @@
}
],
"extensions": {
+ "_custom": {
+ "unknownsource": [
+ {
+ "name": "unknownsource",
+ "value": "z",
+ "attrs": {},
+ "children": {}
+ }
+ ]
+ },
"dc": {
"rights": [
{
@@ -32,6 +42,16 @@
}
},
"extensions": {
+ "_custom": {
+ "unknownentry": [
+ {
+ "name": "unknownentry",
+ "value": "y",
+ "attrs": {},
+ "children": {}
+ }
+ ]
+ },
"dc": {
"subject": [
{
@@ -72,6 +92,16 @@
}
],
"extensions": {
+ "_custom": {
+ "unknownfeed": [
+ {
+ "name": "unknownfeed",
+ "value": "x",
+ "attrs": {},
+ "children": {}
+ }
+ ]
+ },
"dc": {
"creator": [
{
diff --git a/testdata/parser/rss/issue_303_encoded_no_namespace.json b/testdata/parser/rss/issue_303_encoded_no_namespace.json
index 9cc9cced..1222b6ad 100644
--- a/testdata/parser/rss/issue_303_encoded_no_namespace.json
+++ b/testdata/parser/rss/issue_303_encoded_no_namespace.json
@@ -2,6 +2,18 @@
"items": [
{
"title": "First",
+ "extensions": {
+ "_custom": {
+ "encoded": [
+ {
+ "name": "encoded",
+ "value": "hello",
+ "attrs": {},
+ "children": {}
+ }
+ ]
+ }
+ },
"custom": {
"encoded": "hello"
}
diff --git a/testdata/parser/rss/rss_channel_custom_elements.json b/testdata/parser/rss/rss_channel_custom_elements.json
new file mode 100644
index 00000000..e80ab5f2
--- /dev/null
+++ b/testdata/parser/rss/rss_channel_custom_elements.json
@@ -0,0 +1,35 @@
+{
+ "title": "Test Feed",
+ "extensions": {
+ "_custom": {
+ "customFeedId": [
+ {
+ "name": "customFeedId",
+ "value": "feed-123",
+ "attrs": {},
+ "children": {}
+ }
+ ],
+ "customMetadata": [
+ {
+ "name": "customMetadata",
+ "value": "Some metadata",
+ "attrs": {
+ "type": "internal"
+ },
+ "children": {}
+ }
+ ],
+ "updateFrequency": [
+ {
+ "name": "updateFrequency",
+ "value": "hourly",
+ "attrs": {},
+ "children": {}
+ }
+ ]
+ }
+ },
+ "items": [],
+ "version": "2.0"
+}
diff --git a/testdata/parser/rss/rss_channel_custom_elements.xml b/testdata/parser/rss/rss_channel_custom_elements.xml
new file mode 100644
index 00000000..2cc220fc
--- /dev/null
+++ b/testdata/parser/rss/rss_channel_custom_elements.xml
@@ -0,0 +1,11 @@
+
+
+
+ Test Feed
+ feed-123
+ hourly
+ Some metadata
+
+
\ No newline at end of file
diff --git a/testdata/parser/rss/rss_channel_item_custom.json b/testdata/parser/rss/rss_channel_item_custom.json
index 5e739a61..a487aa30 100644
--- a/testdata/parser/rss/rss_channel_item_custom.json
+++ b/testdata/parser/rss/rss_channel_item_custom.json
@@ -1,6 +1,26 @@
{
"items": [
{
+ "extensions": {
+ "_custom": {
+ "apcategory": [
+ {
+ "name": "apcategory",
+ "value": "s",
+ "attrs": {},
+ "children": {}
+ }
+ ],
+ "test": [
+ {
+ "name": "test",
+ "value": "test",
+ "attrs": {},
+ "children": {}
+ }
+ ]
+ }
+ },
"custom": {
"apcategory": "s",
"test": "test"
diff --git a/testdata/parser/rss/rss_channel_item_custom_and_extension.json b/testdata/parser/rss/rss_channel_item_custom_and_extension.json
new file mode 100644
index 00000000..45791ceb
--- /dev/null
+++ b/testdata/parser/rss/rss_channel_item_custom_and_extension.json
@@ -0,0 +1,45 @@
+{
+ "items": [
+ {
+ "title": "Test Item",
+ "extensions": {
+ "_custom": {
+ "customTag": [
+ {
+ "name": "customTag",
+ "value": "Custom Content",
+ "attrs": {},
+ "children": {}
+ }
+ ],
+ "internalId": [
+ {
+ "name": "internalId",
+ "value": "12345",
+ "attrs": {},
+ "children": {}
+ }
+ ]
+ },
+ "media": {
+ "content": [
+ {
+ "name": "content",
+ "value": "",
+ "attrs": {
+ "type": "image/jpeg",
+ "url": "http://example.com/media.jpg"
+ },
+ "children": {}
+ }
+ ]
+ }
+ },
+ "custom": {
+ "customTag": "Custom Content",
+ "internalId": "12345"
+ }
+ }
+ ],
+ "version": "2.0"
+}
diff --git a/testdata/parser/rss/rss_channel_item_custom_and_extension.xml b/testdata/parser/rss/rss_channel_item_custom_and_extension.xml
new file mode 100644
index 00000000..312e1b53
--- /dev/null
+++ b/testdata/parser/rss/rss_channel_item_custom_and_extension.xml
@@ -0,0 +1,13 @@
+
+
+
+ -
+ Test Item
+ Custom Content
+
+ 12345
+
+
+
\ No newline at end of file
diff --git a/testdata/parser/rss/rss_channel_item_custom_cdata.json b/testdata/parser/rss/rss_channel_item_custom_cdata.json
new file mode 100644
index 00000000..c3d34719
--- /dev/null
+++ b/testdata/parser/rss/rss_channel_item_custom_cdata.json
@@ -0,0 +1,31 @@
+{
+ "items": [
+ {
+ "extensions": {
+ "_custom": {
+ "htmlContent": [
+ {
+ "name": "htmlContent",
+ "value": "\u003cdiv\u003eSome \u003cb\u003eHTML\u003c/b\u003e content\u003c/div\u003e",
+ "attrs": {},
+ "children": {}
+ }
+ ],
+ "script": [
+ {
+ "name": "script",
+ "value": "function test() {\n return \"Hello \u0026 \u003cWorld\u003e\";\n }",
+ "attrs": {},
+ "children": {}
+ }
+ ]
+ }
+ },
+ "custom": {
+ "htmlContent": "\u003cdiv\u003eSome \u003cb\u003eHTML\u003c/b\u003e content\u003c/div\u003e",
+ "script": "function test() {\n return \"Hello \u0026 \u003cWorld\u003e\";\n }"
+ }
+ }
+ ],
+ "version": "2.0"
+}
diff --git a/testdata/parser/rss/rss_channel_item_custom_cdata.xml b/testdata/parser/rss/rss_channel_item_custom_cdata.xml
new file mode 100644
index 00000000..9d7c9886
--- /dev/null
+++ b/testdata/parser/rss/rss_channel_item_custom_cdata.xml
@@ -0,0 +1,15 @@
+
+
+
+ -
+ Some HTML content]]>
+
+
+
+
\ No newline at end of file
diff --git a/testdata/parser/rss/rss_channel_item_custom_multiple.json b/testdata/parser/rss/rss_channel_item_custom_multiple.json
new file mode 100644
index 00000000..7dc2629f
--- /dev/null
+++ b/testdata/parser/rss/rss_channel_item_custom_multiple.json
@@ -0,0 +1,53 @@
+{
+ "items": [
+ {
+ "extensions": {
+ "_custom": {
+ "customId": [
+ {
+ "name": "customId",
+ "value": "123",
+ "attrs": {
+ "type": "primary"
+ },
+ "children": {}
+ },
+ {
+ "name": "customId",
+ "value": "456",
+ "attrs": {
+ "type": "secondary"
+ },
+ "children": {}
+ }
+ ],
+ "tag": [
+ {
+ "name": "tag",
+ "value": "First Tag",
+ "attrs": {},
+ "children": {}
+ },
+ {
+ "name": "tag",
+ "value": "Second Tag",
+ "attrs": {},
+ "children": {}
+ },
+ {
+ "name": "tag",
+ "value": "Third Tag",
+ "attrs": {},
+ "children": {}
+ }
+ ]
+ }
+ },
+ "custom": {
+ "customId": "456",
+ "tag": "Third Tag"
+ }
+ }
+ ],
+ "version": "2.0"
+}
diff --git a/testdata/parser/rss/rss_channel_item_custom_multiple.xml b/testdata/parser/rss/rss_channel_item_custom_multiple.xml
new file mode 100644
index 00000000..88b7647a
--- /dev/null
+++ b/testdata/parser/rss/rss_channel_item_custom_multiple.xml
@@ -0,0 +1,14 @@
+
+
+
+ -
+ First Tag
+ Second Tag
+ Third Tag
+ 123
+ 456
+
+
+
\ No newline at end of file
diff --git a/testdata/parser/rss/rss_channel_item_custom_nested.json b/testdata/parser/rss/rss_channel_item_custom_nested.json
new file mode 100644
index 00000000..dc881ee6
--- /dev/null
+++ b/testdata/parser/rss/rss_channel_item_custom_nested.json
@@ -0,0 +1,49 @@
+{
+ "items": [
+ {
+ "extensions": {
+ "_custom": {
+ "event": [
+ {
+ "name": "event",
+ "value": "",
+ "attrs": {},
+ "children": {
+ "date": [
+ {
+ "name": "date",
+ "value": "2026-07-04",
+ "attrs": {},
+ "children": {}
+ }
+ ],
+ "venue": [
+ {
+ "name": "venue",
+ "value": "The \u003cOld\u003e Hall",
+ "attrs": {
+ "city": "Austin"
+ },
+ "children": {}
+ }
+ ]
+ }
+ }
+ ],
+ "simple": [
+ {
+ "name": "simple",
+ "value": "plain",
+ "attrs": {},
+ "children": {}
+ }
+ ]
+ }
+ },
+ "custom": {
+ "simple": "plain"
+ }
+ }
+ ],
+ "version": "2.0"
+}
diff --git a/testdata/parser/rss/rss_channel_item_custom_nested.xml b/testdata/parser/rss/rss_channel_item_custom_nested.xml
new file mode 100644
index 00000000..ebba26aa
--- /dev/null
+++ b/testdata/parser/rss/rss_channel_item_custom_nested.xml
@@ -0,0 +1,16 @@
+
+
+
+ -
+
+ Hall]]>
+ 2026-07-04
+
+ plain
+
+
+
diff --git a/testdata/parser/rss/rss_channel_item_custom_with_attrs.json b/testdata/parser/rss/rss_channel_item_custom_with_attrs.json
new file mode 100644
index 00000000..eb3f7362
--- /dev/null
+++ b/testdata/parser/rss/rss_channel_item_custom_with_attrs.json
@@ -0,0 +1,37 @@
+{
+ "items": [
+ {
+ "extensions": {
+ "_custom": {
+ "customField": [
+ {
+ "name": "customField",
+ "value": "Custom Value",
+ "attrs": {
+ "id": "123",
+ "type": "special"
+ },
+ "children": {}
+ }
+ ],
+ "rating": [
+ {
+ "name": "rating",
+ "value": "8",
+ "attrs": {
+ "reviewer": "John",
+ "scale": "1-10"
+ },
+ "children": {}
+ }
+ ]
+ }
+ },
+ "custom": {
+ "customField": "Custom Value",
+ "rating": "8"
+ }
+ }
+ ],
+ "version": "2.0"
+}
diff --git a/testdata/parser/rss/rss_channel_item_custom_with_attrs.xml b/testdata/parser/rss/rss_channel_item_custom_with_attrs.xml
new file mode 100644
index 00000000..be899c41
--- /dev/null
+++ b/testdata/parser/rss/rss_channel_item_custom_with_attrs.xml
@@ -0,0 +1,11 @@
+
+
+
+ -
+ Custom Value
+ 8
+
+
+
\ No newline at end of file
diff --git a/testdata/translator/rss/feed_item_category_-_rss_channel_item_custom.json b/testdata/translator/rss/feed_item_category_-_rss_channel_item_custom.json
index 24add264..389dc7a5 100644
--- a/testdata/translator/rss/feed_item_category_-_rss_channel_item_custom.json
+++ b/testdata/translator/rss/feed_item_category_-_rss_channel_item_custom.json
@@ -1,6 +1,26 @@
{
"items": [
{
+ "extensions": {
+ "_custom": {
+ "apcategory": [
+ {
+ "name": "apcategory",
+ "value": "s",
+ "attrs": {},
+ "children": {}
+ }
+ ],
+ "test": [
+ {
+ "name": "test",
+ "value": "test",
+ "attrs": {},
+ "children": {}
+ }
+ ]
+ }
+ },
"custom": {
"apcategory": "s",
"test": "test"