├── .gitignore
├── LICENSE
├── README.md
├── example
└── main.go
├── go.mod
├── hkn.go
├── hkn_test.go
├── http.go
├── item.go
├── story.go
├── updates.go
└── user.go
/.gitignore:
--------------------------------------------------------------------------------
1 | # Binaries for programs and plugins
2 | *.exe
3 | *.exe~
4 | *.dll
5 | *.so
6 | *.dylib
7 |
8 | # Test binary, built with `go test -c`
9 | *.test
10 |
11 | # Output of the go coverage tool, specifically when used with LiteIDE
12 | *.out
13 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Luka Kerr
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # hkn
2 |
3 |
4 | [](https://godoc.org/github.com/lukakerr/hkn)
5 |
6 | A go module for interacting with Hacker News.
7 |
8 | ### Features
9 |
10 | A ticked checkbox indicates the feature currently exists in `master`.
11 |
12 | An item refers to either a story, comment, ask, job, poll or poll part
13 |
14 | - [x] Get a single item
15 | - [x] Get multiple items
16 | - [x] Get largest item id
17 | - [x] Get top 500 new, top and best stories (or a number >= 0, <= 500)
18 | - [x] Get top 200 ask, show and job stories (or a number >= 0, <= 200)
19 | - [x] Get changed items and profiles
20 | - [x] Get a user
21 | - [ ] Get a user's submissions
22 | - [ ] Get a user's comments
23 | - [ ] Get a user's hidden items
24 | - [ ] Get a user's upvoted items
25 | - [ ] Get a user's favorited items
26 | - [x] Login a user
27 | - [x] Upvote an item
28 | - [x] Unvote a comment
29 | - [ ] Downvote a comment
30 | - [x] Create a story
31 | - [ ] Create a poll
32 | - [x] Create a comment
33 | - [ ] Flag an item
34 | - [ ] Hide an item
35 | - [ ] Favorite an item
36 | - [ ] Edit an item
37 | - [ ] Delete an item
38 | - [ ] Search
39 | - [ ] Full text
40 | - [ ] By tag
41 | - [ ] By created at date
42 | - [ ] By points
43 | - [ ] By number of comments
44 | - [ ] By page number
45 | - [ ] Sorted by relevance, then points, then number of comments
46 | - [ ] Sorted by most recent
47 |
48 | ### Usage
49 |
50 | First get `hkn`:
51 |
52 | ```bash
53 | $ go get github.com/lukakerr/hkn
54 | ```
55 |
56 | Import into your project:
57 |
58 | ```go
59 | import "github.com/lukakerr/hkn"
60 |
61 | // or
62 |
63 | import (
64 | "github.com/lukakerr/hkn"
65 | )
66 | ```
67 |
68 | #### Methods
69 |
70 | Examples of all methods on the client can be found in [example/main.go](./example/main.go).
71 |
72 | First create a client:
73 |
74 | ```go
75 | client := hkn.NewClient()
76 | ```
77 |
78 | Various methods can then be then called on the client:
79 |
80 | **Get a single item by id**
81 |
82 | ```go
83 | // Returns (Item, error)
84 | item, err := client.GetItem(8869)
85 | ```
86 |
87 | **Get multiple items by ids**
88 |
89 | ```go
90 | // Returns ([]Item, error)
91 | items, err := client.GetItems([]int{8869, 8908, 8881, 10403, 9125})
92 | ```
93 |
94 | **Get max item id**
95 |
96 | ```go
97 | // Returns (int, error)
98 | id, err := client.GetMaxItemID()
99 | ```
100 |
101 | **Get the latest item and profile updates**
102 |
103 | ```go
104 | // Returns (Updates, error)
105 | updates, err := client.GetUpdates()
106 | ```
107 |
108 | **Get top stories given a number**
109 |
110 | ```go
111 | // Returns ([]int, error)
112 | stories, err := client.GetTopStories(20)
113 | ```
114 |
115 | **Get new stories given a number**
116 |
117 | ```go
118 | // Returns ([]int, error)
119 | stories, err := client.GetNewStories(20)
120 | ```
121 |
122 | **Get best stories given a number**
123 |
124 | ```go
125 | // Returns ([]int, error)
126 | stories, err := client.GetBestStories(20)
127 | ```
128 |
129 | **Get latest ask stories given a number**
130 |
131 | ```go
132 | // Returns ([]int, error)
133 | stories, err := client.GetLatestAskStories(20)
134 | ```
135 |
136 | **Get latest show stories given a number**
137 |
138 | ```go
139 | // Returns ([]int, error)
140 | stories, err := client.GetLatestShowStories(20)
141 | ```
142 |
143 | **Get latest job stories given a number**
144 |
145 | ```go
146 | // Returns ([]int, error)
147 | stories, err := client.GetLatestJobStories(20)
148 | ```
149 |
150 | **Get a user by id**
151 |
152 | ```go
153 | // Returns (User, error)
154 | user, err := client.GetUser("jl")
155 | ```
156 |
157 | **Login a user with a username and password**
158 |
159 | ```go
160 | // The cookie returned is used for actions that require a user to be logged in
161 | // Returns (*http.Cookie, error)
162 | cookie, err := client.Login("username", "password")
163 | ```
164 |
165 | **Upvote an item**
166 |
167 | > A cookie is required to upvote, get this from logging in
168 |
169 | ```go
170 | // Returns (bool, error)
171 | upvoted, err := client.Upvote(8869, cookie)
172 | ```
173 |
174 | **Unvote a comment**
175 |
176 | > A cookie is required to unvote, get this from logging in
177 |
178 | ```go
179 | // Returns (bool, error)
180 | unvoted, err := client.Unvote(8869, cookie)
181 | ```
182 |
183 | **Create a comment**
184 |
185 | > A cookie is required to create a comment, get this from logging in
186 |
187 | ```go
188 | // Returns (bool, error)
189 | content := "Really cool."
190 | commented, err := client.Comment(8869, content, cookie)
191 | ```
192 |
193 | **Create a story with a title and URL**
194 |
195 | > A cookie is required to create a story, get this from logging in
196 |
197 | ```go
198 | // Returns (bool, error)
199 | title := "A title."
200 | URL := "https://a.url.com"
201 | created, err := client.CreateStoryWithURL(title, URL, cookie)
202 | ```
203 |
204 | **Create a story with a title and text**
205 |
206 | > A cookie is required to create a story, get this from logging in
207 |
208 | ```go
209 | // Returns (bool, error)
210 | title := "A title."
211 | text := "Some text."
212 | created, err := client.CreateStoryWithText(title, text, cookie)
213 | ```
214 |
215 | ### Running
216 |
217 | To run the example locally:
218 |
219 | ```bash
220 | $ go run example/main.go
221 | ```
222 |
223 | ### Testing
224 |
225 | ```bash
226 | $ go test
227 | ```
228 |
--------------------------------------------------------------------------------
/example/main.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "fmt"
5 |
6 | "github.com/lukakerr/hkn"
7 | )
8 |
9 | var ids = []int{8869, 8908, 8881, 10403, 9125}
10 |
11 | // Various examples of how to interact with the hkn module
12 | // Uncomment each function call to see the result
13 | func main() {
14 | client := hkn.NewClient()
15 |
16 | getItem(client)
17 | // getItems(client)
18 | // getMaxItemID(client)
19 | // getUpdates(client)
20 | // getUser(client)
21 | // login(client)
22 | // getTopStories(client)
23 | // getNewStories(client)
24 | // getBestStories(client)
25 | // getLatestAskStories(client)
26 | // getLatestShowStories(client)
27 | // getLatestJobStories(client)
28 | // upvote(client)
29 | // unvote(client)
30 | // comment(client)
31 | // createStoryWithURL(client)
32 | // createStoryWithText(client)
33 | }
34 |
35 | func getItem(client *hkn.Client) {
36 | item, err := client.GetItem(ids[0])
37 |
38 | if err != nil {
39 | fmt.Println(err)
40 | return
41 | }
42 |
43 | fmt.Printf("%+v\n", item)
44 | }
45 |
46 | func getItems(client *hkn.Client) {
47 | items, err := client.GetItems(ids)
48 |
49 | if err != nil {
50 | fmt.Println(err)
51 | return
52 | }
53 |
54 | for _, item := range items {
55 | fmt.Printf("%+v\n", item)
56 | }
57 | }
58 |
59 | func getMaxItemID(client *hkn.Client) {
60 | id, err := client.GetMaxItemID()
61 |
62 | if err != nil {
63 | fmt.Println(err)
64 | return
65 | }
66 |
67 | fmt.Println(id)
68 | }
69 |
70 | func getUpdates(client *hkn.Client) {
71 | updates, err := client.GetUpdates()
72 |
73 | if err != nil {
74 | fmt.Println(err)
75 | return
76 | }
77 |
78 | fmt.Printf("%+v\n", updates)
79 | }
80 |
81 | func getUser(client *hkn.Client) {
82 | user, err := client.GetUser("jl")
83 |
84 | if err != nil {
85 | fmt.Println(err)
86 | return
87 | }
88 |
89 | fmt.Printf("%+v\n", user)
90 | }
91 |
92 | func login(client *hkn.Client) {
93 | // You'll need to use an actual username and password here
94 | cookie, err := client.Login("username", "password")
95 |
96 | if err != nil {
97 | fmt.Println(err)
98 | return
99 | }
100 |
101 | fmt.Println(cookie)
102 | }
103 |
104 | func getTopStories(client *hkn.Client) {
105 | stories, err := client.GetTopStories(50)
106 |
107 | if err != nil {
108 | fmt.Println(err)
109 | return
110 | }
111 |
112 | fmt.Println(stories)
113 | }
114 |
115 | func getNewStories(client *hkn.Client) {
116 | stories, err := client.GetNewStories(50)
117 |
118 | if err != nil {
119 | fmt.Println(err)
120 | return
121 | }
122 |
123 | fmt.Println(stories)
124 | }
125 |
126 | func getBestStories(client *hkn.Client) {
127 | stories, err := client.GetBestStories(50)
128 |
129 | if err != nil {
130 | fmt.Println(err)
131 | return
132 | }
133 |
134 | fmt.Println(stories)
135 | }
136 |
137 | func getLatestAskStories(client *hkn.Client) {
138 | stories, err := client.GetLatestAskStories(50)
139 |
140 | if err != nil {
141 | fmt.Println(err)
142 | return
143 | }
144 |
145 | fmt.Println(stories)
146 | }
147 |
148 | func getLatestShowStories(client *hkn.Client) {
149 | stories, err := client.GetLatestShowStories(50)
150 |
151 | if err != nil {
152 | fmt.Println(err)
153 | return
154 | }
155 |
156 | fmt.Println(stories)
157 | }
158 |
159 | func getLatestJobStories(client *hkn.Client) {
160 | stories, err := client.GetLatestJobStories(30)
161 |
162 | if err != nil {
163 | fmt.Println(err)
164 | return
165 | }
166 |
167 | fmt.Println(stories)
168 | }
169 |
170 | func upvote(client *hkn.Client) {
171 | cookie, err := client.Login("username", "password")
172 |
173 | if err != nil {
174 | fmt.Println(err)
175 | return
176 | }
177 |
178 | upvoted, err := client.Upvote(ids[0], cookie)
179 |
180 | if err != nil {
181 | fmt.Println(err)
182 | return
183 | }
184 |
185 | fmt.Println(upvoted)
186 | }
187 |
188 | func unvote(client *hkn.Client) {
189 | cookie, err := client.Login("username", "password")
190 |
191 | if err != nil {
192 | fmt.Println(err)
193 | return
194 | }
195 |
196 | unvoted, err := client.Unvote(ids[0], cookie)
197 |
198 | if err != nil {
199 | fmt.Println(err)
200 | return
201 | }
202 |
203 | fmt.Println(unvoted)
204 | }
205 |
206 | func comment(client *hkn.Client) {
207 | cookie, err := client.Login("username", "password")
208 |
209 | if err != nil {
210 | fmt.Println(err)
211 | return
212 | }
213 |
214 | content := "Really cool."
215 | commented, err := client.Comment(ids[0], content, cookie)
216 |
217 | if err != nil {
218 | fmt.Println(err)
219 | return
220 | }
221 |
222 | fmt.Println(commented)
223 | }
224 |
225 | func createStoryWithURL(client *hkn.Client) {
226 | cookie, err := client.Login("username", "password")
227 |
228 | if err != nil {
229 | fmt.Println(err)
230 | return
231 | }
232 |
233 | title := "test"
234 | URL := "https://github.com"
235 | created, err := client.CreateStoryWithURL(title, URL, cookie)
236 |
237 | if err != nil {
238 | fmt.Println(err)
239 | return
240 | }
241 |
242 | fmt.Println(created)
243 | }
244 |
245 | func createStoryWithText(client *hkn.Client) {
246 | cookie, err := client.Login("username", "password")
247 |
248 | if err != nil {
249 | fmt.Println(err)
250 | return
251 | }
252 |
253 | title := "A title"
254 | text := "Some text"
255 | created, err := client.CreateStoryWithText(title, text, cookie)
256 |
257 | if err != nil {
258 | fmt.Println(err)
259 | return
260 | }
261 |
262 | fmt.Println(created)
263 | }
264 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/lukakerr/hkn
2 |
--------------------------------------------------------------------------------
/hkn.go:
--------------------------------------------------------------------------------
1 | /*
2 | Package hkn is a go module for interacting with Hacker News.
3 |
4 | To get started simply import the package, create a client and call methods on the client:
5 |
6 | import (
7 | "fmt"
8 |
9 | "github.com/lukakerr/hkn"
10 | )
11 |
12 | func main() {
13 | client := hkn.NewClient()
14 |
15 | // For example, to get an item by id
16 | item, err := client.GetItem(8869)
17 |
18 | if err != nil {
19 | fmt.Println(err)
20 | return
21 | }
22 |
23 | fmt.Printf("%+v\n", item)
24 | }
25 | */
26 | package hkn
27 |
28 | import (
29 | "errors"
30 | "net/http"
31 | )
32 |
33 | // Client represents the hkn client
34 | type Client struct {
35 | BaseURL string
36 | WebURL string
37 | }
38 |
39 | // NewClient creates a new hkn client
40 | func NewClient() *Client {
41 | c := Client{
42 | BaseURL: "https://hacker-news.firebaseio.com/v0",
43 | WebURL: "https://news.ycombinator.com",
44 | }
45 |
46 | return &c
47 | }
48 |
49 | const (
50 | // A JSON suffix for URLs
51 | jsonSuffix = ".json"
52 | )
53 |
54 | var (
55 | // ErrFetching represents an error fetching a resource
56 | ErrFetching = errors.New("fetching resource failed")
57 |
58 | // ErrEmptyContent represents an error that content provided is empty
59 | ErrEmptyContent = errors.New("content is empty")
60 |
61 | // ErrEmptyTitle represents an error that a title provided is empty
62 | ErrEmptyTitle = errors.New("title is empty")
63 |
64 | // ErrInvalidAuth represents an error authenticating
65 | ErrInvalidAuth = errors.New("invalid username or password")
66 |
67 | // ErrFetchingActionURL represents an error fetching an action URL
68 | ErrFetchingActionURL = errors.New("fetching action URL failed")
69 |
70 | // ErrInvalidNumber represents an error that a number provided is invalid
71 | ErrInvalidNumber = errors.New("invalid number")
72 | )
73 |
74 | // GetItem returns a single item given an id
75 | func (c *Client) GetItem(id int) (Item, error) {
76 | return getItem(id, c.BaseURL)
77 | }
78 |
79 | // GetItems returns a slice of items given a number of ids
80 | func (c *Client) GetItems(ids []int) (Items, error) {
81 | return getItems(ids, c.BaseURL)
82 | }
83 |
84 | // GetMaxItemID returns the most recent item id
85 | func (c *Client) GetMaxItemID() (int, error) {
86 | return getMaxItemID(c.BaseURL)
87 | }
88 |
89 | // GetUpdates returns the latest item and profile updates
90 | func (c *Client) GetUpdates() (Updates, error) {
91 | return getUpdates(c.BaseURL)
92 | }
93 |
94 | // GetUser returns a user given an id
95 | func (c *Client) GetUser(id string) (User, error) {
96 | return getUser(id, c.BaseURL)
97 | }
98 |
99 | // Login a user given a username and password and get back an authentication cookie
100 | func (c *Client) Login(username string, password string) (*http.Cookie, error) {
101 | return login(username, password, c.WebURL)
102 | }
103 |
104 | // GetTopStories returns top stories given a number
105 | func (c *Client) GetTopStories(number int) ([]int, error) {
106 | return getTopStories(number, c.BaseURL)
107 | }
108 |
109 | // GetNewStories returns new stories given a number
110 | func (c *Client) GetNewStories(number int) ([]int, error) {
111 | return getNewStories(number, c.BaseURL)
112 | }
113 |
114 | // GetBestStories returns best stories given a number
115 | func (c *Client) GetBestStories(number int) ([]int, error) {
116 | return getBestStories(number, c.BaseURL)
117 | }
118 |
119 | // GetLatestAskStories returns latest ask stories given a number
120 | func (c *Client) GetLatestAskStories(number int) ([]int, error) {
121 | return getLatestAskStories(number, c.BaseURL)
122 | }
123 |
124 | // GetLatestShowStories returns latest show stories given a number
125 | func (c *Client) GetLatestShowStories(number int) ([]int, error) {
126 | return getLatestShowStories(number, c.BaseURL)
127 | }
128 |
129 | // GetLatestJobStories returns latest job stories given a number
130 | func (c *Client) GetLatestJobStories(number int) ([]int, error) {
131 | return getLatestJobStories(number, c.BaseURL)
132 | }
133 |
134 | // Upvote an item given an id and cookie and get back whether the upvote was successful
135 | func (c *Client) Upvote(id int, cookie *http.Cookie) (bool, error) {
136 | return upvote(id, cookie, c.WebURL)
137 | }
138 |
139 | // Unvote a comment given an id and cookie and get back whether the unvote was successful
140 | func (c *Client) Unvote(id int, cookie *http.Cookie) (bool, error) {
141 | return unvote(id, cookie, c.WebURL)
142 | }
143 |
144 | // Comment creates a comment on an item given an id, content and cookie, and returns whether the comment was successful
145 | func (c *Client) Comment(id int, content string, cookie *http.Cookie) (bool, error) {
146 | return comment(id, content, cookie, c.WebURL)
147 | }
148 |
149 | // CreateStoryWithURL creates a story with a mandatory title and optional url
150 | func (c *Client) CreateStoryWithURL(title string, url string, cookie *http.Cookie) (bool, error) {
151 | return createStory(title, url, cookie, c.WebURL, "url")
152 | }
153 |
154 | // CreateStoryWithText creates a story with a mandatory title and optional text body
155 | func (c *Client) CreateStoryWithText(title string, text string, cookie *http.Cookie) (bool, error) {
156 | return createStory(title, text, cookie, c.WebURL, "text")
157 | }
158 |
--------------------------------------------------------------------------------
/hkn_test.go:
--------------------------------------------------------------------------------
1 | package hkn
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "net/http/httptest"
8 | "reflect"
9 | "testing"
10 | )
11 |
12 | var (
13 | mux *http.ServeMux
14 | client *Client
15 | server *httptest.Server
16 | )
17 |
18 | func setup() {
19 | mux = http.NewServeMux()
20 | server = httptest.NewServer(mux)
21 | client = NewClient()
22 |
23 | client.BaseURL = server.URL
24 | }
25 |
26 | func teardown() {
27 | server.Close()
28 | }
29 |
30 | func TestGetItem(t *testing.T) {
31 | setup()
32 | defer teardown()
33 |
34 | jsonItem := `{
35 | "by" : "dhouston",
36 | "descendants" : 71,
37 | "id" : 8863,
38 | "kids" : [9224, 8952, 8917],
39 | "score" : 104,
40 | "time" : 1175714200,
41 | "title" : "My YC app: Dropbox - Throw away your USB drive",
42 | "type" : "story",
43 | "url" : "http://www.getdropbox.com/u/2/screencast.html"
44 | }`
45 |
46 | mux.HandleFunc("/item/8863.json", func(w http.ResponseWriter, r *http.Request) {
47 | fmt.Fprint(w, jsonItem)
48 | })
49 |
50 | expected := Item{}
51 |
52 | _ = json.Unmarshal([]byte(jsonItem), &expected)
53 |
54 | item, err := client.GetItem(8863)
55 |
56 | if err != nil {
57 | t.Errorf("Error for GetItem(8863) should have been nil. Was: %v", err)
58 | }
59 |
60 | if !reflect.DeepEqual(item, expected) {
61 | t.Errorf("GetItem(8863) returned %+v, was expecting %+v", item, expected)
62 | }
63 | }
64 |
65 | func TestGetUser(t *testing.T) {
66 | setup()
67 | defer teardown()
68 |
69 | jsonUser := `{
70 | "about" : "This is a test",
71 | "created" : 1173923446,
72 | "id" : "jl",
73 | "karma" : 4094,
74 | "submitted": [18498213, 16659709, 16659632, 16659556]
75 | }`
76 |
77 | mux.HandleFunc("/user/jl.json", func(w http.ResponseWriter, r *http.Request) {
78 | fmt.Fprint(w, jsonUser)
79 | })
80 |
81 | expected := User{}
82 |
83 | _ = json.Unmarshal([]byte(jsonUser), &expected)
84 |
85 | user, err := client.GetUser("jl")
86 |
87 | if err != nil {
88 | t.Errorf("Error for GetUser('jl') should have been nil. Was: %v", err)
89 | }
90 |
91 | if !reflect.DeepEqual(user, expected) {
92 | t.Errorf("GetUser('jl') returned %+v, was expecting %+v", user, expected)
93 | }
94 | }
95 |
96 | func TestGetMaxItemId(t *testing.T) {
97 | setup()
98 | defer teardown()
99 |
100 | maxItem := `123456`
101 |
102 | mux.HandleFunc("/maxitem.json", func(w http.ResponseWriter, r *http.Request) {
103 | fmt.Fprint(w, maxItem)
104 | })
105 |
106 | var expected int
107 |
108 | _ = json.Unmarshal([]byte(maxItem), &expected)
109 |
110 | id, err := client.GetMaxItemID()
111 |
112 | if err != nil {
113 | t.Errorf("Error for GetMaxItemId() should have been nil. Was: %v", err)
114 | }
115 |
116 | if !reflect.DeepEqual(id, expected) {
117 | t.Errorf("GetMaxItemId() returned %+v, was expecting %+v", id, expected)
118 | }
119 | }
120 |
121 | func TestGetUpdates(t *testing.T) {
122 | setup()
123 | defer teardown()
124 |
125 | jsonUpdates := `{
126 | "items" : [8423305, 8420805, 8423379, 8422504],
127 | "profiles" : ["thefox", "mdda", "plinkplonk", "GBond", "rqebmm", "neom"]
128 | }`
129 |
130 | mux.HandleFunc("/updates.json", func(w http.ResponseWriter, r *http.Request) {
131 | fmt.Fprint(w, jsonUpdates)
132 | })
133 |
134 | var expected Updates
135 |
136 | _ = json.Unmarshal([]byte(jsonUpdates), &expected)
137 |
138 | updates, err := client.GetUpdates()
139 |
140 | if err != nil {
141 | t.Errorf("Error for GetUpdates() should have been nil. Was: %v", err)
142 | }
143 |
144 | if !reflect.DeepEqual(updates, expected) {
145 | t.Errorf("GetUpdates() returned %+v, was expecting %+v", updates, expected)
146 | }
147 | }
148 |
--------------------------------------------------------------------------------
/http.go:
--------------------------------------------------------------------------------
1 | package hkn
2 |
3 | import (
4 | "io/ioutil"
5 | "net/http"
6 | "net/url"
7 | "regexp"
8 | "strings"
9 | "time"
10 | )
11 |
12 | // Create a new HTTP client with sensible options
13 | func newHTTPClient() *http.Client {
14 | return &http.Client{
15 | // A 10 second timeout
16 | Timeout: time.Second * 10,
17 |
18 | // Don't follow 301 redirects
19 | CheckRedirect: func(req *http.Request, via []*http.Request) error {
20 | return http.ErrUseLastResponse
21 | },
22 | }
23 | }
24 |
25 | // Perform a GET request and return the response
26 | func get(resource string, cookie *http.Cookie) (*http.Response, error) {
27 | // Build the request
28 | req, err := http.NewRequest("GET", resource, nil)
29 | req.Close = true
30 |
31 | if cookie != nil {
32 | req.AddCookie(cookie)
33 | }
34 |
35 | if err != nil {
36 | return nil, ErrFetching
37 | }
38 |
39 | client := newHTTPClient()
40 |
41 | resp, err := client.Do(req)
42 |
43 | if err != nil {
44 | return nil, err
45 | }
46 |
47 | return resp, nil
48 | }
49 |
50 | // Get the content from a http response and close the response
51 | func getContent(resp *http.Response) ([]byte, error) {
52 | defer resp.Body.Close()
53 |
54 | body, err := ioutil.ReadAll(resp.Body)
55 |
56 | if err != nil {
57 | return nil, err
58 | }
59 |
60 | return body, nil
61 | }
62 |
63 | // Perform a GET request and return the body as a slice of bytes
64 | func getBody(resource string) ([]byte, error) {
65 | resp, err := get(resource, nil)
66 |
67 | if err != nil {
68 | return nil, err
69 | }
70 |
71 | return getContent(resp)
72 | }
73 |
74 | // Perform a GET request with a cookie and return the body as a slice of bytes
75 | func getBodyWithCookie(resource string, cookie *http.Cookie) ([]byte, error) {
76 | resp, err := get(resource, cookie)
77 |
78 | if err != nil {
79 | return nil, err
80 | }
81 |
82 | return getContent(resp)
83 | }
84 |
85 | // Perform a POST request and return the response
86 | func post(resource string, urlEncodedValues url.Values, cookie *http.Cookie) (*http.Response, error) {
87 | req, err := http.NewRequest("POST", resource, strings.NewReader(urlEncodedValues.Encode()))
88 |
89 | if err != nil {
90 | return nil, err
91 | }
92 |
93 | req.Close = true
94 |
95 | if cookie != nil {
96 | req.AddCookie(cookie)
97 | }
98 |
99 | req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
100 | req.Header.Set("Access-Control-Allow-Origin", "*")
101 |
102 | client := newHTTPClient()
103 |
104 | resp, err := client.Do(req)
105 |
106 | if err != nil {
107 | return nil, err
108 | }
109 |
110 | return resp, nil
111 | }
112 |
113 | // Perform a POST request with a cookie
114 | func postWithCookie(resource string, urlEncodedValues url.Values, cookie *http.Cookie) ([]byte, error) {
115 | resp, err := post(resource, urlEncodedValues, cookie)
116 |
117 | if err != nil {
118 | return nil, err
119 | }
120 |
121 | return getContent(resp)
122 | }
123 |
124 | // Perform a POST request and return the first cookie in the response
125 | func postAndGetCookie(resource string, urlEncodedValues url.Values) (*http.Cookie, error) {
126 | resp, err := post(resource, urlEncodedValues, nil)
127 |
128 | if err != nil {
129 | return &http.Cookie{}, err
130 | }
131 |
132 | defer resp.Body.Close()
133 |
134 | cookies := resp.Cookies()
135 |
136 | if len(cookies) == 0 {
137 | return &http.Cookie{}, ErrInvalidAuth
138 | }
139 |
140 | return cookies[0], nil
141 | }
142 |
143 | func matchRegexFromBody(webURL string, regex string, cookie *http.Cookie) (string, error) {
144 | resp, err := getBodyWithCookie(webURL, cookie)
145 |
146 | if err != nil {
147 | return "", err
148 | }
149 |
150 | r := regexp.MustCompile(regex)
151 |
152 | result := r.FindStringSubmatch(string(resp))
153 |
154 | if len(result) == 2 {
155 | return result[1], nil
156 | }
157 |
158 | return "", ErrFetchingActionURL
159 | }
160 |
--------------------------------------------------------------------------------
/item.go:
--------------------------------------------------------------------------------
1 | package hkn
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "html"
7 | "net/http"
8 | "net/url"
9 | "sync"
10 | )
11 |
12 | // Item represents a Hacker News item
13 | type Item struct {
14 | ID int `json:"id"`
15 | Deleted bool `json:"deleted"`
16 | Type string `json:"type"`
17 | By string `json:"by"`
18 | Time int32 `json:"time"`
19 | Text string `json:"text"`
20 | Dead bool `json:"dead"`
21 | Parent int `json:"parent"`
22 | Poll int `json:"poll"`
23 | Kids []int `json:"kids"`
24 | URL string `json:"url"`
25 | Score int `json:"score"`
26 | Title string `json:"title"`
27 | Parts []int `json:"parts"`
28 | Descendants int `json:"descendants"`
29 | }
30 |
31 | // Items represents an array of items
32 | type Items []Item
33 |
34 | const (
35 | voteURLRegex = `]*?\s+)?href=['"]([^'"]*)['"]`
36 | commentURLRegex = `]*?\s+)?value=['"]([^'"]*)['"]`
37 | )
38 |
39 | // Get an item given an id
40 | func getItem(id int, apiURL string) (Item, error) {
41 | reqURL := fmt.Sprintf("%s/%s/%d", apiURL, "item", id) + jsonSuffix
42 |
43 | resp, err := getBody(reqURL)
44 |
45 | var item Item
46 |
47 | if err != nil {
48 | return item, err
49 | }
50 |
51 | err = json.Unmarshal(resp, &item)
52 | return item, err
53 | }
54 |
55 | // Get items given a slice of ids
56 | // This function is concurrent and thus does not guarantee order
57 | func getItems(ids []int, apiURL string) (Items, error) {
58 | var (
59 | items Items
60 | wg sync.WaitGroup
61 | )
62 |
63 | // Add length of ids to the WaitGroup
64 | wg.Add(len(ids))
65 |
66 | for _, id := range ids {
67 | // Spawn a thread for each item
68 | go func(id int, url string) {
69 | defer wg.Done()
70 |
71 | item, err := getItem(id, url)
72 |
73 | if err != nil {
74 | return
75 | }
76 |
77 | items = append(items, item)
78 | }(id, apiURL)
79 | }
80 |
81 | // Wait until all threads are done
82 | wg.Wait()
83 |
84 | return items, nil
85 | }
86 |
87 | // Get the most recent item id
88 | func getMaxItemID(apiURL string) (int, error) {
89 | reqURL := fmt.Sprintf("%s/%s", apiURL, "maxitem") + jsonSuffix
90 |
91 | resp, err := getBody(reqURL)
92 |
93 | var id int
94 |
95 | if err != nil {
96 | return id, err
97 | }
98 |
99 | err = json.Unmarshal(resp, &id)
100 | return id, err
101 | }
102 |
103 | func vote(id int, cookie *http.Cookie, webURL string, voteType string) (bool, error) {
104 | reqURL := fmt.Sprintf("%s/%s?id=%d", webURL, "item", id)
105 | upvoteRegex := fmt.Sprintf(voteURLRegex, voteType, id)
106 |
107 | voteAuth, err := matchRegexFromBody(reqURL, upvoteRegex, cookie)
108 |
109 | if err != nil {
110 | return false, err
111 | }
112 |
113 | voteURL := fmt.Sprintf("%s/%s", webURL, voteAuth)
114 | unescaped := html.UnescapeString(voteURL)
115 |
116 | resp, err := getBodyWithCookie(unescaped, cookie)
117 |
118 | if err == nil && resp != nil {
119 | return true, nil
120 | }
121 |
122 | return false, err
123 | }
124 |
125 | // Upvote an item given an id and a cookie
126 | func upvote(id int, cookie *http.Cookie, webURL string) (bool, error) {
127 | return vote(id, cookie, webURL, "up")
128 | }
129 |
130 | // Unvote a comment given an id and a cookie
131 | func unvote(id int, cookie *http.Cookie, webURL string) (bool, error) {
132 | return vote(id, cookie, webURL, "un")
133 | }
134 |
135 | // Create a comment on an item given an id and content
136 | func comment(id int, content string, cookie *http.Cookie, webURL string) (bool, error) {
137 | if len(content) == 0 {
138 | return false, ErrEmptyContent
139 | }
140 |
141 | reqURL := fmt.Sprintf("%s/%s?id=%d", webURL, "item", id)
142 |
143 | commentAuth, err := matchRegexFromBody(reqURL, commentURLRegex, cookie)
144 |
145 | if err != nil {
146 | return false, err
147 | }
148 |
149 | commentURL := fmt.Sprintf("%s/%s", webURL, "comment")
150 |
151 | body := url.Values{}
152 | body.Set("parent", fmt.Sprintf("%d", id))
153 | body.Set("goto", fmt.Sprintf("item?id=%d", id))
154 | body.Set("hmac", commentAuth)
155 | body.Set("text", content)
156 |
157 | resp, err := postWithCookie(commentURL, body, cookie)
158 |
159 | if err == nil && resp != nil {
160 | return true, nil
161 | }
162 |
163 | return false, err
164 | }
165 |
--------------------------------------------------------------------------------
/story.go:
--------------------------------------------------------------------------------
1 | package hkn
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "net/url"
8 | )
9 |
10 | const (
11 | createStoryFormRegex = `]*?\s+)?value=['"]([^'"]*)['"]`
12 | )
13 |
14 | // Given a number a limit and a url, fetch from the url and
15 | // return the number requested if it is >= 0 and <= limit
16 | func getNumber(number int, limit int, url string) ([]int, error) {
17 | if number > limit || number < 0 {
18 | return nil, ErrInvalidNumber
19 | }
20 |
21 | resp, err := getBody(url + jsonSuffix)
22 |
23 | var top []int
24 |
25 | if err != nil {
26 | return top, err
27 | }
28 |
29 | err = json.Unmarshal(resp, &top)
30 |
31 | if err != nil {
32 | return top, err
33 | }
34 |
35 | if len(top) < number {
36 | return top, nil
37 | }
38 |
39 | return top[:number], nil
40 | }
41 |
42 | // Get top stories given a number
43 | func getTopStories(number int, apiURL string) ([]int, error) {
44 | resource := fmt.Sprintf("%s/%s", apiURL, "topstories")
45 | return getNumber(number, 500, resource)
46 | }
47 |
48 | // Get new stories given a number
49 | func getNewStories(number int, apiURL string) ([]int, error) {
50 | resource := fmt.Sprintf("%s/%s", apiURL, "newstories")
51 | return getNumber(number, 500, resource)
52 | }
53 |
54 | // Get best stories given a number
55 | func getBestStories(number int, apiURL string) ([]int, error) {
56 | resource := fmt.Sprintf("%s/%s", apiURL, "beststories")
57 | return getNumber(number, 500, resource)
58 | }
59 |
60 | // Get latest ask stories given a number
61 | func getLatestAskStories(number int, apiURL string) ([]int, error) {
62 | resource := fmt.Sprintf("%s/%s", apiURL, "askstories")
63 | return getNumber(number, 200, resource)
64 | }
65 |
66 | // Get latest show stories given a number
67 | func getLatestShowStories(number int, apiURL string) ([]int, error) {
68 | resource := fmt.Sprintf("%s/%s", apiURL, "showstories")
69 | return getNumber(number, 200, resource)
70 | }
71 |
72 | // Get latest job stories given a number
73 | func getLatestJobStories(number int, apiURL string) ([]int, error) {
74 | resource := fmt.Sprintf("%s/%s", apiURL, "jobstories")
75 | return getNumber(number, 200, resource)
76 | }
77 |
78 | // Create a story given a title, content, cookie and content key (either "text" or "url")
79 | func createStory(title string, content string, cookie *http.Cookie, webURL string, contentKey string) (bool, error) {
80 | if len(title) == 0 {
81 | return false, ErrEmptyTitle
82 | }
83 |
84 | submitFormURL := fmt.Sprintf("%s/%s", webURL, "submit")
85 | fnID, err := matchRegexFromBody(submitFormURL, createStoryFormRegex, cookie)
86 |
87 | if err != nil {
88 | return false, err
89 | }
90 |
91 | submitURL := fmt.Sprintf("%s/%s", webURL, "r")
92 |
93 | body := url.Values{}
94 | body.Set("fnid", fnID)
95 | body.Set("fnop", "submit-page")
96 | body.Set("title", title)
97 |
98 | if contentKey == "text" {
99 | body.Set("url", "")
100 | body.Set("text", content)
101 | } else if contentKey == "url" {
102 | body.Set("url", content)
103 | body.Set("text", "")
104 | }
105 |
106 | resp, err := postWithCookie(submitURL, body, cookie)
107 |
108 | if err == nil && resp != nil {
109 | return true, nil
110 | }
111 |
112 | return false, err
113 | }
114 |
--------------------------------------------------------------------------------
/updates.go:
--------------------------------------------------------------------------------
1 | package hkn
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | )
7 |
8 | // Updates represents profile and item updates
9 | type Updates struct {
10 | Items []int `json:"items"`
11 | Profiles []string `json:"profiles"`
12 | }
13 |
14 | // Get the latest item and profile updates
15 | func getUpdates(apiURL string) (Updates, error) {
16 | reqURL := fmt.Sprintf("%s/%s", apiURL, "updates") + jsonSuffix
17 |
18 | resp, err := getBody(reqURL)
19 |
20 | var updates Updates
21 |
22 | if err != nil {
23 | return updates, err
24 | }
25 |
26 | err = json.Unmarshal(resp, &updates)
27 | return updates, err
28 | }
29 |
--------------------------------------------------------------------------------
/user.go:
--------------------------------------------------------------------------------
1 | package hkn
2 |
3 | import (
4 | "encoding/json"
5 | "fmt"
6 | "net/http"
7 | "net/url"
8 | )
9 |
10 | // User represents a Hacker News user
11 | type User struct {
12 | ID string `json:"id"`
13 | Delay int `json:"delay"`
14 | Created int32 `json:"created"`
15 | Karma int `json:"karma"`
16 | About string `json:"about"`
17 | Submitted []int `json:"submitted"`
18 | }
19 |
20 | // Get a user given an id
21 | func getUser(id string, apiURL string) (User, error) {
22 | reqURL := fmt.Sprintf("%s/%s/%s", apiURL, "user", id) + jsonSuffix
23 |
24 | resp, err := getBody(reqURL)
25 |
26 | var user User
27 |
28 | if err != nil {
29 | return user, err
30 | }
31 |
32 | err = json.Unmarshal(resp, &user)
33 | return user, err
34 | }
35 |
36 | // Login a user given a username and password
37 | func login(username string, password string, webURL string) (*http.Cookie, error) {
38 | reqURL := fmt.Sprintf("%s/%s", webURL, "login")
39 |
40 | body := url.Values{}
41 | body.Set("acct", username)
42 | body.Set("pw", password)
43 | body.Set("goto", "news")
44 |
45 | cookie, err := postAndGetCookie(reqURL, body)
46 |
47 | return cookie, err
48 | }
49 |
--------------------------------------------------------------------------------