all repos

rss-tools @ 97e6bf5d8882b12205658af7ee1de0dd8d42298a

get rss feed from sources that(i need and) dont provide one
2 files changed, 241 insertions(+), 12 deletions(-)
update feed generator
Author: Oleksandr Smirnov olexsmir@gmail.com
Committed at: 2026-04-22 20:13:38 +0300
Authored at: 2026-04-20 14:46:02 +0300
Change ID: skqotspuvoqsnksolkmuxymnowqsvqnu
Parent: d1f2b81
M app/atom.go
···
        1
        1
         package app

      
        2
        2
         

      
        3
        3
         import (

      
        
        4
        +	"bytes"

      
        
        5
        +	"crypto/sha1"

      
        4
        6
         	"encoding/xml"

      
        
        7
        +	"fmt"

      
        
        8
        +	"io"

      
        5
        9
         	"net/http"

      
        6
        10
         	"time"

      
        7
        11
         )

      
        8
        12
         

      
        9
        13
         type AtomFeed struct {

      
        10
        
        -	XMLName xml.Name    `xml:"feed"`

      
        11
        
        -	XMLNS   string      `xml:"xmlns,attr"`

      
        12
        
        -	Title   string      `xml:"title"`

      
        13
        
        -	ID      string      `xml:"id"`

      
        14
        
        -	Updated string      `xml:"updated"`

      
        15
        
        -	Entries []AtomEntry `xml:"entry"`

      
        
        14
        +	XMLName  xml.Name    `xml:"feed"`

      
        
        15
        +	XMLNS    string      `xml:"xmlns,attr"`

      
        
        16
        +	Title    string      `xml:"title"`

      
        
        17
        +	ID       string      `xml:"id"`

      
        
        18
        +	Updated  string      `xml:"updated"`

      
        
        19
        +	Subtitle string      `xml:"subtitle,omitempty"`

      
        
        20
        +	Entries  []AtomEntry `xml:"entry"`

      
        16
        21
         }

      
        17
        22
         

      
        18
        23
         type AtomEntry struct {

      ···
        28
        33
         	Value   string   `xml:",chardata"`

      
        29
        34
         }

      
        30
        35
         

      
        
        36
        +type FeedEntry struct {

      
        
        37
        +	Title       string

      
        
        38
        +	ID          string

      
        
        39
        +	Content     string

      
        
        40
        +	ContentType string // "text" or "html", defaults to "text"

      
        
        41
        +	Updated     time.Time

      
        
        42
        +}

      
        
        43
        +

      
        
        44
        +type FeedOption func(*AtomFeed)

      
        
        45
        +

      
        
        46
        +func WithFeedSubtitle(subtitle string) FeedOption {

      
        
        47
        +	return func(f *AtomFeed) {

      
        
        48
        +		f.Subtitle = subtitle

      
        
        49
        +	}

      
        
        50
        +}

      
        
        51
        +

      
        
        52
        +func WithFeedUpdated(updated time.Time) FeedOption {

      
        
        53
        +	return func(f *AtomFeed) {

      
        
        54
        +		if !updated.IsZero() {

      
        
        55
        +			f.Updated = updated.Format(time.RFC3339)

      
        
        56
        +		}

      
        
        57
        +	}

      
        
        58
        +}

      
        
        59
        +

      
        31
        60
         type FeedBuilder struct {

      
        32
        61
         	feed AtomFeed

      
        33
        62
         }

      
        34
        63
         

      
        35
        
        -func NewFeed(title, id string) *FeedBuilder {

      
        36
        
        -	return &FeedBuilder{feed: AtomFeed{

      
        
        64
        +func NewFeed(title, id string, opts ...FeedOption) *FeedBuilder {

      
        
        65
        +	builder := &FeedBuilder{feed: AtomFeed{

      
        37
        66
         		XMLNS:   "http://www.w3.org/2005/Atom",

      
        38
        67
         		Title:   title,

      
        39
        68
         		ID:      id,

      
        40
        69
         		Updated: time.Now().Format(time.RFC3339),

      
        41
        70
         	}}

      
        
        71
        +	for _, opt := range opts {

      
        
        72
        +		opt(&builder.feed)

      
        
        73
        +	}

      
        
        74
        +	return builder

      
        42
        75
         }

      
        43
        76
         

      
        44
        77
         func (f *FeedBuilder) Add(title, id, content string, date time.Time) *FeedBuilder {

      
        45
        
        -	f.feed.Entries = append(f.feed.Entries, AtomEntry{

      
        
        78
        +	return f.AddEntry(FeedEntry{

      
        46
        79
         		Title:   title,

      
        47
        80
         		ID:      id,

      
        48
        
        -		Updated: date.Format(time.RFC3339),

      
        
        81
        +		Content: content,

      
        
        82
        +		Updated: date,

      
        
        83
        +	})

      
        
        84
        +}

      
        
        85
        +

      
        
        86
        +func (f *FeedBuilder) AddText(title, content string, updated time.Time) *FeedBuilder {

      
        
        87
        +	return f.AddEntry(FeedEntry{

      
        
        88
        +		Title:   title,

      
        49
        89
         		Content: content,

      
        
        90
        +		Updated: updated,

      
        50
        91
         	})

      
        
        92
        +}

      
        
        93
        +

      
        
        94
        +func (f *FeedBuilder) AddEntry(entry FeedEntry) *FeedBuilder {

      
        
        95
        +	if entry.Updated.IsZero() {

      
        
        96
        +		entry.Updated = time.Now()

      
        
        97
        +	}

      
        
        98
        +	if entry.ID == "" {

      
        
        99
        +		hash := sha1.Sum(fmt.Appendf(nil, "%s|%s|%s", entry.Title, entry.Content, entry.Updated.Format(time.RFC3339Nano)))

      
        
        100
        +		entry.ID = fmt.Sprintf("urn:sha1:%x", hash)

      
        
        101
        +	}

      
        
        102
        +

      
        
        103
        +	f.feed.Entries = append(f.feed.Entries, AtomEntry{

      
        
        104
        +		Title:   entry.Title,

      
        
        105
        +		ID:      entry.ID,

      
        
        106
        +		Updated: entry.Updated.Format(time.RFC3339),

      
        
        107
        +		Content: AtomContent{

      
        
        108
        +			Type:  contentType,

      
        
        109
        +			Value: entry.Content,

      
        
        110
        +		},

      
        
        111
        +	})

      
        
        112
        +

      
        
        113
        +	feedUpdated, err := time.Parse(time.RFC3339, f.feed.Updated)

      
        
        114
        +	if err != nil || entry.Updated.After(feedUpdated) {

      
        
        115
        +		f.feed.Updated = entry.Updated.Format(time.RFC3339)

      
        
        116
        +	}

      
        51
        117
         	return f

      
        52
        118
         }

      
        53
        119
         

      
        54
        
        -func (f *FeedBuilder) Render(w http.ResponseWriter) error {

      
        55
        
        -	w.Header().Set("Content-Type", "application/atom+xml")

      
        
        120
        +func (f *FeedBuilder) SetUpdated(updated time.Time) *FeedBuilder {

      
        
        121
        +	if !updated.IsZero() {

      
        
        122
        +		f.feed.Updated = updated.Format(time.RFC3339)

      
        
        123
        +	}

      
        
        124
        +	return f

      
        
        125
        +}

      
        
        126
        +

      
        
        127
        +func (f *FeedBuilder) WriteTo(w io.Writer) error {

      
        56
        128
         	enc := xml.NewEncoder(w)

      
        57
        129
         	enc.Indent("", "  ")

      
        58
        130
         	return enc.Encode(f.feed)

      
        59
        131
         }

      
        
        132
        +

      
        
        133
        +func (f *FeedBuilder) Bytes() ([]byte, error) {

      
        
        134
        +	var buf bytes.Buffer

      
        
        135
        +	if err := f.WriteTo(&buf); err != nil {

      
        
        136
        +		return nil, err

      
        
        137
        +	}

      
        
        138
        +	return buf.Bytes(), nil

      
        
        139
        +}

      
        
        140
        +

      
        
        141
        +func (f *FeedBuilder) Render(w http.ResponseWriter) error {

      
        
        142
        +	w.Header().Set("Content-Type", "application/atom+xml; charset=utf-8")

      
        
        143
        +	return f.WriteTo(w)

      
        
        144
        +}

      
A app/atom_test.go
···
        
        1
        +package app

      
        
        2
        +

      
        
        3
        +import (

      
        
        4
        +	"bytes"

      
        
        5
        +	"encoding/xml"

      
        
        6
        +	"net/http"

      
        
        7
        +	"net/http/httptest"

      
        
        8
        +	"strings"

      
        
        9
        +	"testing"

      
        
        10
        +	"time"

      
        
        11
        +

      
        
        12
        +	"olexsmir.xyz/x/is"

      
        
        13
        +)

      
        
        14
        +

      
        
        15
        +func TestFeedBuilderAddEntryDefaults(t *testing.T) {

      
        
        16
        +	feed := NewFeed("test", "feed-id")

      
        
        17
        +	feed.AddEntry(FeedEntry{Title: "entry", Content: "body"})

      
        
        18
        +

      
        
        19
        +	is.Equal(t, 1, len(feed.f.Entries))

      
        
        20
        +	entry := feed.f.Entries[0]

      
        
        21
        +	is.NotEqual(t, "", entry.ID)

      
        
        22
        +	is.NotEqual(t, "", entry.Updated)

      
        
        23
        +}

      
        
        24
        +

      
        
        25
        +func TestFeedBuilderBytesAndWriteTo(t *testing.T) {

      
        
        26
        +	updated := time.Date(2026, 4, 20, 12, 30, 0, 0, time.UTC)

      
        
        27
        +	feed := NewFeed("test", "feed-id", WithFeedSubtitle("subtitle")).AddText("entry", "content", updated)

      
        
        28
        +

      
        
        29
        +	raw, err := feed.Bytes()

      
        
        30
        +	is.Err(t, err, nil)

      
        
        31
        +	if !bytes.Contains(raw, []byte("<subtitle>subtitle</subtitle>")) {

      
        
        32
        +		t.Fatalf("expected subtitle in serialized feed")

      
        
        33
        +	}

      
        
        34
        +

      
        
        35
        +	var parsed AtomFeed

      
        
        36
        +	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        
        37
        +	is.Equal(t, "test", parsed.Title)

      
        
        38
        +}

      
        
        39
        +

      
        
        40
        +func TestFeedBuilderRender(t *testing.T) {

      
        
        41
        +	r := httptest.NewRecorder()

      
        
        42
        +	err := NewFeed("test", "feed-id").

      
        
        43
        +		Add("entry", "entry-id", "content", time.Date(2026, 4, 20, 8, 0, 0, 0, time.UTC)).

      
        
        44
        +		Render(r)

      
        
        45
        +	is.Err(t, err, nil)

      
        
        46
        +

      
        
        47
        +	is.Equal(t, http.StatusOK, r.Code)

      
        
        48
        +	if got := r.Header().Get("Content-Type"); !strings.Contains(got, "application/atom+xml") {

      
        
        49
        +		t.Fatalf("unexpected content type: %q", got)

      
        
        50
        +	}

      
        
        51
        +}

      
        
        52
        +

      
        
        53
        +func TestFeedEntryTextContent(t *testing.T) {

      
        
        54
        +	feed := NewFeed("test", "feed-id").

      
        
        55
        +		AddEntry(FeedEntry{

      
        
        56
        +			Title:       "text entry",

      
        
        57
        +			Content:     "plain text content",

      
        
        58
        +			ContentType: "text",

      
        
        59
        +			Updated:     time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC),

      
        
        60
        +		})

      
        
        61
        +

      
        
        62
        +	raw, err := feed.Bytes()

      
        
        63
        +	is.Err(t, err, nil)

      
        
        64
        +	if !strings.Contains(string(raw), `<content type="text">plain text content</content>`) {

      
        
        65
        +		t.Fatalf("expected text content with type attribute in serialized feed")

      
        
        66
        +	}

      
        
        67
        +

      
        
        68
        +	var parsed AtomFeed

      
        
        69
        +	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        
        70
        +	is.Equal(t, 1, len(parsed.Entries))

      
        
        71
        +

      
        
        72
        +	entry := parsed.Entries[0]

      
        
        73
        +	is.Equal(t, "text", entry.Content.Type)

      
        
        74
        +	is.Equal(t, "plain text content", entry.Content.Value)

      
        
        75
        +}

      
        
        76
        +

      
        
        77
        +func TestFeedEntryHtmlContent(t *testing.T) {

      
        
        78
        +	htmlContent := "<p>Hello <strong>World</strong></p>"

      
        
        79
        +	feed := NewFeed("test", "feed-id").

      
        
        80
        +		AddEntry(FeedEntry{

      
        
        81
        +			Title:       "html entry",

      
        
        82
        +			Content:     htmlContent,

      
        
        83
        +			ContentType: "html",

      
        
        84
        +			Updated:     time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC),

      
        
        85
        +		})

      
        
        86
        +

      
        
        87
        +	raw, err := feed.Bytes()

      
        
        88
        +	is.Err(t, err, nil)

      
        
        89
        +	if !strings.Contains(string(raw), `<content type="html">`) {

      
        
        90
        +		t.Fatalf("expected HTML content with type='html' attribute in serialized feed")

      
        
        91
        +	}

      
        
        92
        +

      
        
        93
        +	var parsed AtomFeed

      
        
        94
        +	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        
        95
        +	is.Equal(t, 1, len(parsed.Entries))

      
        
        96
        +

      
        
        97
        +	entry := parsed.Entries[0]

      
        
        98
        +	is.Equal(t, "html", entry.Content.Type)

      
        
        99
        +	is.Equal(t, htmlContent, entry.Content.Value)

      
        
        100
        +}

      
        
        101
        +

      
        
        102
        +func TestFeedMultipleEntriesWithMixedContentTypes(t *testing.T) {

      
        
        103
        +	updated := time.Date(2026, 4, 20, 12, 0, 0, 0, time.UTC)

      
        
        104
        +	feed := NewFeed("test", "feed-id").

      
        
        105
        +		AddEntry(FeedEntry{

      
        
        106
        +			Title:       "text entry",

      
        
        107
        +			Content:     "plain text",

      
        
        108
        +			ContentType: "text",

      
        
        109
        +			Updated:     updated,

      
        
        110
        +		}).

      
        
        111
        +		AddEntry(FeedEntry{

      
        
        112
        +			Title:       "html entry",

      
        
        113
        +			Content:     "<p>html content</p>",

      
        
        114
        +			ContentType: "html",

      
        
        115
        +			Updated:     updated,

      
        
        116
        +		}).

      
        
        117
        +		AddEntry(FeedEntry{

      
        
        118
        +			Title:   "default entry",

      
        
        119
        +			Content: "default content",

      
        
        120
        +			Updated: updated,

      
        
        121
        +		})

      
        
        122
        +

      
        
        123
        +	raw, err := feed.Bytes()

      
        
        124
        +	is.Err(t, err, nil)

      
        
        125
        +

      
        
        126
        +	var parsed AtomFeed

      
        
        127
        +	is.Err(t, xml.Unmarshal(raw, &parsed), nil)

      
        
        128
        +	is.Equal(t, 3, len(parsed.Entries))

      
        
        129
        +

      
        
        130
        +	tests := []struct {

      
        
        131
        +		name         string

      
        
        132
        +		expectedType string

      
        
        133
        +		expectedText string

      
        
        134
        +	}{

      
        
        135
        +		{"text entry", "text", "plain text"},

      
        
        136
        +		{"html entry", "html", "<p>html content</p>"},

      
        
        137
        +		{"default entry", "text", "default content"},

      
        
        138
        +	}

      
        
        139
        +	for i, tc := range tests {

      
        
        140
        +		is.Equal(t, tc.name, parsed.Entries[i].Title)

      
        
        141
        +		is.Equal(t, tc.expectedText, parsed.Entries[i].Content.Value)

      
        
        142
        +		is.Equal(t, tc.expectedType, parsed.Entries[i].Content.Type)

      
        
        143
        +	}

      
        
        144
        +}