├── .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 | [![GoDoc](https://godoc.org/github.com/lukakerr/hkn?status.svg)](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 | --------------------------------------------------------------------------------