diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..959dded --- /dev/null +++ b/.env.template @@ -0,0 +1 @@ +AUTH_COOKIE= \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2eea525 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +.env \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..0cd9338 --- /dev/null +++ b/README.md @@ -0,0 +1,7 @@ +# hnbot + +> Hello, I am a bot crossposting top posts from HN. +> +> I curate content to only post stuff which could be interesting for the SN community on a best-efforts basis. + +-- https://stacker.news/items/161788 diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b2158ce --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module gitlab.com/ekzyis/hnbot + +go 1.20 + +require ( + github.com/joho/godotenv v1.5.1 // indirect + github.com/namsral/flag v1.7.4-pre // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..4c16f8b --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/namsral/flag v1.7.4-pre h1:b2ScHhoCUkbsq0d2C15Mv+VU8bl8hAXV8arnWiOHNZs= +github.com/namsral/flag v1.7.4-pre/go.mod h1:OXldTctbM6SWH1K899kPZcf65KxJiD7MsceFUpB5yDo= diff --git a/main.go b/main.go new file mode 100644 index 0000000..ebb3707 --- /dev/null +++ b/main.go @@ -0,0 +1,150 @@ +package main + +import ( + "bytes" + "encoding/json" + "fmt" + "log" + "net/http" + + "github.com/joho/godotenv" + "github.com/namsral/flag" +) + +var ( + AuthCookie string +) + +func init() { + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + flag.StringVar(&AuthCookie, "auth_cookie", "", "Cookie required for authorization") + flag.Parse() + if AuthCookie == "" { + log.Fatal("auth cookie not set") + } +} + +type ItemID = int + +type Story struct { + ID ItemID + By string // username of author + Time int // UNIX timestamp + Descendants int // number of comments + Kids []ItemID + Score int + Title string + Url string +} + +type GraphQLPayload struct { + Query string `json:"query"` + Variables map[string]interface{} `json:"variables,omitempty"` +} + +func fetchTopStoriesFromHN() []Story { + // API docs: https://github.com/HackerNews/API + + url := "https://hacker-news.firebaseio.com/v0/topstories.json" + resp, err := http.Get(url) + if err != nil { + log.Fatal("Error fetching top stories:", err) + } + defer resp.Body.Close() + log.Printf("GET %s %d\n", url, resp.StatusCode) + + var ids []int + err = json.NewDecoder(resp.Body).Decode(&ids) + if err != nil { + log.Fatal("Error decoding top stories JSON:", err) + } + + // we are only interested in the first page of top stories + const limit = 30 + ids = ids[:limit] + + var stories [limit]Story + for i, id := range ids { + story := fetchStoryByID(id) + stories[i] = story + } + + // Can't return [30]Story as []Story so we copy the array + return stories[:] +} + +func fetchStoryByID(id ItemID) Story { + url := fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id) + resp, err := http.Get(url) + if err != nil { + log.Fatal("Error fetching story:", err) + } + defer resp.Body.Close() + log.Printf("GET %s %d\n", url, resp.StatusCode) + + var story Story + err = json.NewDecoder(resp.Body).Decode(&story) + if err != nil { + log.Fatal("Error decoding story JSON:", err) + } + + return story +} + +func filterByRelevanceForSN(stories *[]Story) *[]Story { + // TODO: filter by relevance + + slice := (*stories)[0:1] + return &slice +} + +func postToSN(story *Story) { + // TODO: check for dupes first + + body := GraphQLPayload{ + Query: ` + mutation upsertLink($url: String!, $title: String!) { + upsertLink(url: $url, title: $title) { + id + } + } + `, + Variables: map[string]interface{}{ + "url": story.Url, + "title": story.Title, + }, + } + + bodyJSON, err := json.Marshal(body) + if err != nil { + log.Fatal("Error during json.Marshal:", err) + } + + url := "https://stacker.news/api/graphql" + req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyJSON)) + if err != nil { + panic(err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Cookie", AuthCookie) + + client := http.DefaultClient + resp, err := client.Do(req) + if err != nil { + panic(err) + } + defer resp.Body.Close() + + log.Printf("POST %s %d\n", url, resp.StatusCode) +} + +func main() { + stories := fetchTopStoriesFromHN() + filtered := filterByRelevanceForSN(&stories) + for _, story := range *filtered { + postToSN(&story) + } +}