@@ -2,7 +2,7 @@ package sources
22
33import (
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+
1719type HackerNewsSource struct {}
1820
1921func (HackerNewsSource ) Key () string { return "hackernews" }
2022func (HackerNewsSource ) Label () string { return "Hacker News" }
2123func (HackerNewsSource ) HomePageURL () string { return "https://news.ycombinator.com/" }
2224
2325func (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
6061func 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