Skip to content
This repository was archived by the owner on Jun 22, 2026. It is now read-only.

Commit 658d135

Browse files
authored
Merge pull request #8 from boringcode-dev/fix/hackernews-timeout
fix: harden upstream feed fetching
2 parents 95cac67 + 35aebed commit 658d135

9 files changed

Lines changed: 456 additions & 109 deletions

File tree

internal/service/http_test.go

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package service
2+
3+
import (
4+
"io"
5+
"net/http"
6+
"strings"
7+
"testing"
8+
)
9+
10+
type roundTripperFunc func(*http.Request) (*http.Response, error)
11+
12+
func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
13+
return fn(req)
14+
}
15+
16+
func TestUserAgentTransportSetsConfiguredUserAgent(t *testing.T) {
17+
transport := &userAgentTransport{
18+
base: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
19+
if got := req.Header.Get("User-Agent"); got != "feedreader/0.1" {
20+
t.Fatalf("unexpected user-agent: %q", got)
21+
}
22+
return &http.Response{
23+
StatusCode: http.StatusOK,
24+
Body: io.NopCloser(strings.NewReader("ok")),
25+
Header: make(http.Header),
26+
}, nil
27+
}),
28+
userAgent: "feedreader/0.1",
29+
}
30+
31+
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
32+
if err != nil {
33+
t.Fatalf("new request: %v", err)
34+
}
35+
resp, err := transport.RoundTrip(req)
36+
if err != nil {
37+
t.Fatalf("round trip: %v", err)
38+
}
39+
_ = resp.Body.Close()
40+
}
41+
42+
func TestUserAgentTransportPreservesExplicitUserAgent(t *testing.T) {
43+
transport := &userAgentTransport{
44+
base: roundTripperFunc(func(req *http.Request) (*http.Response, error) {
45+
if got := req.Header.Get("User-Agent"); got != "custom-agent/1.0" {
46+
t.Fatalf("unexpected user-agent: %q", got)
47+
}
48+
return &http.Response{
49+
StatusCode: http.StatusOK,
50+
Body: io.NopCloser(strings.NewReader("ok")),
51+
Header: make(http.Header),
52+
}, nil
53+
}),
54+
userAgent: "feedreader/0.1",
55+
}
56+
57+
req, err := http.NewRequest(http.MethodGet, "https://example.com", nil)
58+
if err != nil {
59+
t.Fatalf("new request: %v", err)
60+
}
61+
req.Header.Set("User-Agent", "custom-agent/1.0")
62+
resp, err := transport.RoundTrip(req)
63+
if err != nil {
64+
t.Fatalf("round trip: %v", err)
65+
}
66+
_ = resp.Body.Close()
67+
}

internal/service/service.go

Lines changed: 29 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,39 @@ func New(cfg config.Config, repo *repository.SQLiteRepository) *FeedService {
2727
cfg: cfg,
2828
repo: repo,
2929
sources: sources.Build(),
30-
client: &http.Client{
31-
Timeout: time.Duration(cfg.RequestTimeoutSec * float64(time.Second)),
30+
client: newHTTPClient(cfg),
31+
}
32+
}
33+
34+
func newHTTPClient(cfg config.Config) *http.Client {
35+
return &http.Client{
36+
Timeout: time.Duration(cfg.RequestTimeoutSec * float64(time.Second)),
37+
Transport: &userAgentTransport{
38+
base: http.DefaultTransport,
39+
userAgent: strings.TrimSpace(cfg.UserAgent),
3240
},
3341
}
3442
}
3543

44+
type userAgentTransport struct {
45+
base http.RoundTripper
46+
userAgent string
47+
}
48+
49+
func (t *userAgentTransport) RoundTrip(req *http.Request) (*http.Response, error) {
50+
transport := t.base
51+
if transport == nil {
52+
transport = http.DefaultTransport
53+
}
54+
if strings.TrimSpace(t.userAgent) == "" || req.Header.Get("User-Agent") != "" {
55+
return transport.RoundTrip(req)
56+
}
57+
clone := req.Clone(req.Context())
58+
clone.Header = req.Header.Clone()
59+
clone.Header.Set("User-Agent", t.userAgent)
60+
return transport.RoundTrip(clone)
61+
}
62+
3663
func (s *FeedService) StartScheduler(ctx context.Context) {
3764
go func() {
3865
location := loadScheduleLocation()

internal/sources/alphaxiv.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,7 @@ func (AlphaXivSource) Label() string { return "alphaXiv" }
2121
func (AlphaXivSource) HomePageURL() string { return "https://www.alphaxiv.org/" }
2222

2323
func (s AlphaXivSource) Fetch(ctx context.Context, client *http.Client) ([]domain.FeedItem, error) {
24-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.HomePageURL(), nil)
25-
if err != nil {
26-
return nil, err
27-
}
28-
resp, err := client.Do(req)
24+
resp, err := getWithRetry(ctx, client, s.HomePageURL())
2925
if err != nil {
3026
return nil, err
3127
}

internal/sources/github.go

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,11 +19,7 @@ func (GitHubTrendingSource) Label() string { return "GitHub Trending" }
1919
func (GitHubTrendingSource) HomePageURL() string { return "https://github.com/trending" }
2020

2121
func (s GitHubTrendingSource) Fetch(ctx context.Context, client *http.Client) ([]domain.FeedItem, error) {
22-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, s.HomePageURL(), nil)
23-
if err != nil {
24-
return nil, err
25-
}
26-
resp, err := client.Do(req)
22+
resp, err := getWithRetry(ctx, client, s.HomePageURL())
2723
if err != nil {
2824
return nil, err
2925
}

internal/sources/hackernews.go

Lines changed: 50 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package sources
22

33
import (
44
"context"
5-
"encoding/xml"
5+
"encoding/json"
66
"html"
77
"io"
88
"net/http"
@@ -14,18 +14,16 @@ import (
1414
"feedreader/internal/domain"
1515
)
1616

17+
const hackerNewsFrontPageAPI = "https://hn.algolia.com/api/v1/search?tags=front_page"
18+
1719
type HackerNewsSource struct{}
1820

1921
func (HackerNewsSource) Key() string { return "hackernews" }
2022
func (HackerNewsSource) Label() string { return "Hacker News" }
2123
func (HackerNewsSource) HomePageURL() string { return "https://news.ycombinator.com/" }
2224

2325
func (s HackerNewsSource) Fetch(ctx context.Context, client *http.Client) ([]domain.FeedItem, error) {
24-
req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://hnrss.org/frontpage", nil)
25-
if err != nil {
26-
return nil, err
27-
}
28-
resp, err := client.Do(req)
26+
resp, err := getWithRetry(ctx, client, hackerNewsFrontPageAPI)
2927
if err != nil {
3028
return nil, err
3129
}
@@ -41,115 +39,76 @@ func (s HackerNewsSource) Fetch(ctx context.Context, client *http.Client) ([]dom
4139
return parseHackerNews(body)
4240
}
4341

44-
type hnRSS struct {
45-
Channel struct {
46-
Items []hnItem `xml:"item"`
47-
} `xml:"channel"`
42+
type hnFrontPage struct {
43+
Hits []hnStory `json:"hits"`
4844
}
4945

50-
type hnItem struct {
51-
Title string `xml:"title"`
52-
Description string `xml:"description"`
53-
PubDate string `xml:"pubDate"`
54-
Link string `xml:"link"`
55-
Comments string `xml:"comments"`
56-
Guid string `xml:"guid"`
57-
Creator string `xml:"creator"`
46+
type hnStory struct {
47+
ObjectID string `json:"objectID"`
48+
StoryID int `json:"story_id"`
49+
Title string `json:"title"`
50+
StoryTitle string `json:"story_title"`
51+
URL string `json:"url"`
52+
StoryURL string `json:"story_url"`
53+
StoryText string `json:"story_text"`
54+
CommentText string `json:"comment_text"`
55+
Author string `json:"author"`
56+
Points *int `json:"points"`
57+
NumComments *int `json:"num_comments"`
58+
CreatedAt string `json:"created_at"`
5859
}
5960

6061
func parseHackerNews(payload []byte) ([]domain.FeedItem, error) {
61-
var rss hnRSS
62-
if err := xml.Unmarshal(payload, &rss); err != nil {
62+
var rss hnFrontPage
63+
if err := json.Unmarshal(payload, &rss); err != nil {
6364
return nil, err
6465
}
65-
items := make([]domain.FeedItem, 0, len(rss.Channel.Items))
66-
for idx, node := range rss.Channel.Items {
67-
var publishedAt *time.Time
68-
if node.PubDate != "" {
69-
if parsed, err := time.Parse(time.RFC1123Z, node.PubDate); err == nil {
70-
t := parsed.UTC()
71-
publishedAt = &t
72-
}
66+
items := make([]domain.FeedItem, 0, len(rss.Hits))
67+
for idx, node := range rss.Hits {
68+
externalID := strings.TrimSpace(node.ObjectID)
69+
if externalID == "" && node.StoryID > 0 {
70+
externalID = strconv.Itoa(node.StoryID)
7371
}
74-
score := extractInt(node.Description, `Points:\s*(\d+)`)
75-
commentsCount := extractInt(node.Description, `# Comments:\s*(\d+)`)
72+
if externalID == "" {
73+
continue
74+
}
75+
commentsURL := "https://news.ycombinator.com/item?id=" + externalID
7676
metadata := map[string]any{}
77-
if commentsCount != nil {
78-
metadata["comments_count"] = *commentsCount
77+
if node.NumComments != nil {
78+
metadata["comments_count"] = *node.NumComments
7979
}
8080
items = append(items, domain.FeedItem{
8181
Source: "hackernews",
82-
ExternalID: extractStoryID(firstNonEmpty(node.Comments, node.Guid, node.Link)),
83-
Title: strings.TrimSpace(node.Title),
84-
URL: strings.TrimSpace(node.Link),
85-
Summary: cleanString(extractHNSummary(node.Description)),
86-
Author: cleanString(strings.TrimSpace(node.Creator)),
87-
Score: score,
88-
CommentsURL: cleanString(strings.TrimSpace(node.Comments)),
89-
PublishedAt: publishedAt,
82+
ExternalID: externalID,
83+
Title: strings.TrimSpace(firstNonEmpty(node.Title, node.StoryTitle, externalID)),
84+
URL: strings.TrimSpace(firstNonEmpty(node.URL, node.StoryURL, commentsURL)),
85+
Summary: cleanString(extractHNSummary(firstNonEmpty(node.StoryText, node.CommentText))),
86+
Author: cleanString(strings.TrimSpace(node.Author)),
87+
Score: node.Points,
88+
CommentsURL: cleanString(commentsURL),
89+
PublishedAt: parseHackerNewsTime(node.CreatedAt),
9090
SourceRank: idx + 1,
9191
Metadata: metadata,
9292
})
9393
}
9494
return items, nil
9595
}
9696

97-
func extractStoryID(value string) string {
98-
re := regexp.MustCompile(`id=(\d+)`)
99-
if match := re.FindStringSubmatch(value); len(match) == 2 {
100-
return match[1]
101-
}
102-
return strings.TrimSpace(value)
103-
}
104-
105-
func extractHNSummary(description string) string {
106-
head := strings.SplitN(description, "<hr>", 2)[0]
107-
replacer := regexp.MustCompile(`<a [^>]+>|</a>|<[^>]+>`)
108-
cleaned := replacer.ReplaceAllString(head, " ")
109-
cleaned = html.UnescapeString(cleaned)
110-
patterns := []*regexp.Regexp{
111-
regexp.MustCompile(`Comments URL:\s*\S+`),
112-
regexp.MustCompile(`Article URL:\s*\S+`),
113-
regexp.MustCompile(`Points:\s*\d+`),
114-
regexp.MustCompile(`# Comments:\s*\d+`),
115-
regexp.MustCompile(`\s+`),
116-
}
117-
for _, pattern := range patterns {
118-
cleaned = pattern.ReplaceAllString(cleaned, " ")
119-
}
120-
return strings.TrimSpace(cleaned)
121-
}
122-
123-
func extractInt(value, pattern string) *int {
124-
re := regexp.MustCompile(pattern)
125-
match := re.FindStringSubmatch(value)
126-
if len(match) != 2 {
127-
return nil
128-
}
129-
parsed := strings.ReplaceAll(match[1], ",", "")
130-
if parsed == "" {
97+
func parseHackerNewsTime(value string) *time.Time {
98+
if strings.TrimSpace(value) == "" {
13199
return nil
132100
}
133-
out, err := strconv.Atoi(parsed)
101+
parsed, err := time.Parse(time.RFC3339, strings.TrimSpace(value))
134102
if err != nil {
135103
return nil
136104
}
137-
return &out
105+
utc := parsed.UTC()
106+
return &utc
138107
}
139108

140-
func firstNonEmpty(values ...string) string {
141-
for _, value := range values {
142-
if strings.TrimSpace(value) != "" {
143-
return value
144-
}
145-
}
146-
return ""
147-
}
148-
149-
func cleanString(value string) *string {
150-
value = strings.TrimSpace(value)
151-
if value == "" {
152-
return nil
153-
}
154-
return &value
109+
func extractHNSummary(description string) string {
110+
cleaned := regexp.MustCompile(`<a [^>]+>|</a>|<[^>]+>`).ReplaceAllString(description, " ")
111+
cleaned = html.UnescapeString(cleaned)
112+
cleaned = regexp.MustCompile(`\s+`).ReplaceAllString(cleaned, " ")
113+
return strings.TrimSpace(cleaned)
155114
}

0 commit comments

Comments
 (0)