Compare commits

...

15 Commits

Author SHA1 Message Date
587dedf7a9 Update to snappy v0.8.0 2025-02-03 23:40:46 +01:00
fb9c1d9fca rename to github.com/ekzyis/hnbot 2025-02-03 23:40:23 +01:00
2cfb0eaa31 refactor into individual packages 2025-02-03 23:40:23 +01:00
7641414693 Update to snappy v0.7.0 2024-12-28 00:50:33 +01:00
73b2f6a430 Update to snappy v0.5.1rc1 2024-07-02 23:56:59 +02:00
cc5a05574a Fix unique constraint hit
For HN reposts, the same SN dupe was found but since sn_items.id was the primary key, a unique constraint was hit.

This meant that posting the same item was attempted over and over again since the HN item id was never found in sn_items.

I manually migrated the database.
2024-04-25 18:24:27 +02:00
0a0470ae67 Ignore hnbot.sqlite3 2024-04-09 02:06:18 +02:00
822ddda1da Update to snappy v0.4.2 2024-04-07 05:49:38 +02:00
3d35aa5e19 Update README.md 2024-04-07 05:45:32 +02:00
8e28ee4691 Remove .env.template 2024-04-07 05:45:25 +02:00
35a4112c22 Remove discord code 2024-04-07 05:42:05 +02:00
24909f5d88 Delete code related to charts 2024-04-07 05:38:57 +02:00
3bf5c8baba Update to snappy v0.4.1 2024-04-07 05:37:31 +02:00
ee95aa89bd Remove chart link and cron.sh
I think it was more annoying than interesting to look at.

Also, it didn't seem to be worth the maintenance of the cronjob etc.
2024-04-04 20:24:02 +02:00
ecda7954ef Query formatting
I first thought that ORDER BY time ASC was missing because I didn't see it.
2024-03-31 16:52:59 +02:00
15 changed files with 263 additions and 580 deletions

View File

@ -1,3 +0,0 @@
SN_AUTH_COOKIE=
DISCORD_TOKEN=
DISCORD_CHANNEL_ID=

6
.gitignore vendored
View File

@ -1,12 +1,6 @@
.env
.vscode
# python virtual environment
venv
# go executable
hnbot
hnbot.sqlite3
*.csv
*.png

View File

@ -2,10 +2,10 @@
> Hello, I am a bot posting top stories from HN.
>
> My original mission was to orange-pill HN by offering the OPs on HN to withdraw the sats their stories received here.
> My original mission was to orange-pill HN by offering the OPs on HN to claim the sats their stories received here.
However, my comments were shadowbanned and ultimately not approved by dang, the site admin.
See this thread: https://stacker.news/items/164155
See this thread: [#164155](https://stacker.news/items/164155)
>
> If you are one of these OPs and want to withdraw your sats, reply to this bio and we will find a solution!
> If you are one of these OPs and want to claim your sats, reply to this bio and we will find a solution!
-- https://stacker.news/items/161788
-- https://stacker.news/hn

10
cron.sh
View File

@ -1,10 +0,0 @@
#!/usr/bin/env bash
set -xe
cd /home/ekzyis/hnbot
sqlite3 hnbot.sqlite3 < hacker_news.csv.sql
venv/bin/python plot.py
mv hn_*.png plots/
rsync plots/* vps:/var/www/files/public/hn/

View File

@ -1,24 +1,29 @@
package main
package db
import (
"database/sql"
"fmt"
"log"
"github.com/ekzyis/hnbot/hn"
_ "github.com/mattn/go-sqlite3"
)
var (
db *sql.DB
_db *sql.DB
)
func init() {
var err error
db, err = sql.Open("sqlite3", "hnbot.sqlite3")
_db, err = sql.Open("sqlite3", "hnbot.sqlite3")
if err != nil {
log.Fatal(err)
}
migrate(db)
migrate(_db)
}
func Query(query string, args ...interface{}) (*sql.Rows, error) {
return _db.Query(query, args...)
}
func migrate(db *sql.DB) {
@ -41,9 +46,10 @@ func migrate(db *sql.DB) {
}
if _, err := db.Exec(`
CREATE TABLE IF NOT EXISTS sn_items (
id INTEGER PRIMARY KEY,
id INTEGER NOT NULL,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
hn_id INTEGER NOT NULL REFERENCES hn_items(id)
hn_id INTEGER NOT NULL REFERENCES hn_items(id),
PRIMARY KEY (id, hn_id)
);
`); err != nil {
err = fmt.Errorf("error during migration: %w", err)
@ -53,7 +59,7 @@ func migrate(db *sql.DB) {
func ItemHasComment(parentId int) bool {
var count int
err := db.QueryRow(`SELECT COUNT(1) FROM comments WHERE parent_id = ?`, parentId).Scan(&count)
err := _db.QueryRow(`SELECT COUNT(1) FROM comments WHERE parent_id = ?`, parentId).Scan(&count)
if err != nil {
err = fmt.Errorf("error during item check: %w", err)
log.Fatal(err)
@ -61,17 +67,17 @@ func ItemHasComment(parentId int) bool {
return count > 0
}
func SaveStories(story *[]Story) error {
func SaveHnItems(story *[]hn.Item) error {
for i, s := range *story {
if err := SaveStory(&s, i+1); err != nil {
if err := SaveHnItem(&s, i+1); err != nil {
return err
}
}
return nil
}
func SaveStory(s *Story, rank int) error {
if _, err := db.Exec(`
func SaveHnItem(s *hn.Item, rank int) error {
if _, err := _db.Exec(`
INSERT INTO hn_items(id, time, title, url, author, ndescendants, score, rank)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
s.ID, s.Time, s.Title, s.Url, s.By, s.Descendants, s.Score, rank); err != nil {
@ -82,7 +88,7 @@ func SaveStory(s *Story, rank int) error {
}
func SaveSnItem(id int, hnId int) error {
if _, err := db.Exec(`INSERT INTO sn_items(id, hn_id) VALUES (?, ?)`, id, hnId); err != nil {
if _, err := _db.Exec(`INSERT INTO sn_items(id, hn_id) VALUES (?, ?)`, id, hnId); err != nil {
err = fmt.Errorf("error during sn item insert: %w", err)
return err
}

View File

@ -1,188 +0,0 @@
package main
import (
"errors"
"fmt"
"log"
"strings"
"github.com/bwmarrin/discordgo"
"github.com/dustin/go-humanize"
"github.com/ekzyis/sn-goapi"
"github.com/joho/godotenv"
"github.com/namsral/flag"
)
var (
DiscordToken string
dg *discordgo.Session
DiscordChannelId string
)
func init() {
err := godotenv.Load()
if err != nil {
log.Fatal("Error loading .env file")
}
flag.StringVar(&DiscordToken, "DISCORD_TOKEN", "", "Discord bot token")
flag.StringVar(&DiscordChannelId, "DISCORD_CHANNEL_ID", "", "Discord channel id")
flag.Parse()
if DiscordToken == "" {
log.Fatal("DISCORD_TOKEN not set")
}
if DiscordChannelId == "" {
log.Fatal("DISCORD_CHANNEL_ID not set")
}
initBot()
}
func initBot() {
var err error
dg, err = discordgo.New("Bot " + DiscordToken)
if err != nil {
log.Fatal("error creating discord session:", err)
}
dg.AddHandler(func(s *discordgo.Session, event *discordgo.Ready) {
log.Println("Logged in as", event.User.Username)
})
dg.AddHandler(onMessage)
dg.AddHandler(onMessageReact)
dg.Identify.Intents = discordgo.IntentsGuildMessages | discordgo.IntentsMessageContent | discordgo.IntentGuildMessageReactions
err = dg.Open()
if err != nil {
log.Fatal("error opening connection to discord: ", err, " -- Is your token correct?")
}
}
func onMessage(s *discordgo.Session, m *discordgo.MessageCreate) {
if m.Author.ID == s.State.User.ID {
return
}
hackerNewsId, err := ParseHackerNewsLink(m.Content)
if err != nil {
return
}
story, err := FetchStoryById(hackerNewsId)
_, err = PostStoryToStackerNews(&story, PostStoryOptions{SkipDupes: false})
if err != nil {
var dupesErr *sn.DupesError
if errors.As(err, &dupesErr) {
SendDupesErrorToDiscord(hackerNewsId, dupesErr)
return
}
SendErrorToDiscord(err)
}
}
func onMessageReact(s *discordgo.Session, reaction *discordgo.MessageReactionAdd) {
if reaction.UserID == s.State.User.ID {
return
}
if reaction.Emoji.Name != "⏭️" {
return
}
m, err := s.ChannelMessage(reaction.ChannelID, reaction.MessageID)
if err != nil {
SendErrorToDiscord(err)
return
}
if len(m.Embeds) == 0 {
return
}
embed := m.Embeds[0]
if !strings.Contains(embed.Title, "dupe(s) found for") {
return
}
id, err := ParseHackerNewsLink(embed.Footer.Text)
if err != nil {
return
}
story, err := FetchStoryById(id)
if err != nil {
SendErrorToDiscord(err)
return
}
id, err = PostStoryToStackerNews(&story, PostStoryOptions{SkipDupes: true})
if err != nil {
SendErrorToDiscord(err)
}
}
func SendDupesErrorToDiscord(hackerNewsId int, dupesErr *sn.DupesError) {
msg := fmt.Sprint(dupesErr)
log.Println(msg)
title := fmt.Sprintf("%d dupe(s) found for %s:", len(dupesErr.Dupes), dupesErr.Url)
color := 0xffc107
var fields []*discordgo.MessageEmbedField
for _, dupe := range dupesErr.Dupes {
fields = append(fields,
&discordgo.MessageEmbedField{
Name: "Title",
Value: dupe.Title,
Inline: false,
},
&discordgo.MessageEmbedField{
Name: "Id",
Value: sn.FormatLink(dupe.Id),
Inline: true,
},
&discordgo.MessageEmbedField{
Name: "Url",
Value: dupe.Url,
Inline: true,
},
&discordgo.MessageEmbedField{
Name: "User",
Value: dupe.User.Name,
Inline: true,
},
&discordgo.MessageEmbedField{
Name: "Created",
Value: humanize.Time(dupe.CreatedAt),
Inline: true,
},
&discordgo.MessageEmbedField{
Name: "Sats",
Value: fmt.Sprint(dupe.Sats),
Inline: true,
},
&discordgo.MessageEmbedField{
Name: "Comments",
Value: fmt.Sprint(dupe.NComments),
Inline: true,
},
)
}
embed := discordgo.MessageEmbed{
Title: title,
Color: color,
Fields: fields,
Footer: &discordgo.MessageEmbedFooter{
Text: HackerNewsItemLink(hackerNewsId),
IconURL: "https://news.ycombinator.com/y18.gif",
},
}
SendEmbedToDiscord(&embed)
}
func SendEmbedToDiscord(embed *discordgo.MessageEmbed) {
_, err := dg.ChannelMessageSendEmbed(DiscordChannelId, embed)
if err != nil {
err = fmt.Errorf("error during sending embed: %w", err)
log.Println(err)
}
}
func SendErrorToDiscord(err error) {
msg := fmt.Sprint(err)
log.Println(msg)
embed := discordgo.MessageEmbed{
Title: "Error",
Color: 0xff0000,
Description: msg,
}
SendEmbedToDiscord(&embed)
}

12
go.mod
View File

@ -1,18 +1,12 @@
module gitlab.com/ekzyis/hnbot
module github.com/ekzyis/hnbot
go 1.20
require (
github.com/bwmarrin/discordgo v0.27.1
github.com/dustin/go-humanize v1.0.1
github.com/ekzyis/sn-goapi v0.3.3
github.com/ekzyis/snappy v0.8.0
github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.22
github.com/namsral/flag v1.7.4-pre
)
require (
github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect
)
require gopkg.in/guregu/null.v4 v4.0.0 // indirect

20
go.sum
View File

@ -1,22 +1,10 @@
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY=
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ekzyis/sn-goapi v0.3.3 h1:5WHGLyYVPwZ12lQrRD40eM+gjWEpDdgdWTshwL8CDEE=
github.com/ekzyis/sn-goapi v0.3.3/go.mod h1:FObbYr/NXgnXNWU+EwiWKoWQy+wAaRS6AoW3NgsJ/Oo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/ekzyis/snappy v0.8.0 h1:e7dRR384XJgNYa1FWNIZmqITSHOSanteBFXQJPfcQwg=
github.com/ekzyis/snappy v0.8.0/go.mod h1:UksYI0dU0+cnzz0LQjWB1P0QQP/ghx47e4atP99a5Lk=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/namsral/flag v1.7.4-pre h1:b2ScHhoCUkbsq0d2C15Mv+VU8bl8hAXV8arnWiOHNZs=
github.com/namsral/flag v1.7.4-pre/go.mod h1:OXldTctbM6SWH1K899kPZcf65KxJiD7MsceFUpB5yDo=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b h1:7mWr3k41Qtv8XlltBkDkl8LoP3mpSgBW8BUoxtEdbXg=
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b/go.mod h1:T9bdIzuCu7OtxOm1hfPfRQxPLYneinmdGuTeoZ9dtd4=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 h1:nxC68pudNYkKU6jWhgrqdreuFiOQWj1Fs7T3VrH4Pjw=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
gopkg.in/guregu/null.v4 v4.0.0 h1:1Wm3S1WEA2I26Kq+6vcW+w0gcDo44YKYD7YIEJNHDjg=
gopkg.in/guregu/null.v4 v4.0.0/go.mod h1:YoQhUrADuG3i9WqesrCmpNRwm1ypAgSHYqoOcTu/JrI=

View File

@ -1,11 +0,0 @@
.headers on
.mode csv
.output hacker_news.csv
SELECT hn.id, hn.created_at, hn.time, hn.title, hn.url, hn.author, hn.ndescendants, hn.score, hn.rank
FROM (
SELECT id, MAX(created_at) AS created_at FROM hn_items
WHERE rank = 1 AND length(title) >= 5
GROUP BY id
ORDER BY time ASC
) t JOIN hn_items hn ON t.id = hn.id
ORDER BY hn.id, hn.created_at DESC;

107
hn.go
View File

@ -1,107 +0,0 @@
package main
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"regexp"
"strconv"
)
type Story struct {
ID int
By string // username of author
Time int // UNIX timestamp
Descendants int // number of comments
Kids []int
Score int
Title string
Url string
}
var (
HackerNewsUrl = "https://news.ycombinator.com"
HackerNewsFirebaseUrl = "https://hacker-news.firebaseio.com/v0"
HackerNewsLinkRegexp = regexp.MustCompile(`(?:https?:\/\/)?news\.ycombinator\.com\/item\?id=([0-9]+)`)
)
func FetchHackerNewsTopStories() ([]Story, error) {
log.Println("Fetching HN top stories ...")
// API docs: https://github.com/HackerNews/API
url := fmt.Sprintf("%s/topstories.json", HackerNewsFirebaseUrl)
resp, err := http.Get(url)
if err != nil {
err = fmt.Errorf("error fetching HN top stories %w:", err)
return nil, err
}
defer resp.Body.Close()
var ids []int
err = json.NewDecoder(resp.Body).Decode(&ids)
if err != nil {
err = fmt.Errorf("error decoding HN top stories JSON: %w", err)
return nil, 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, err := FetchStoryById(id)
if err != nil {
return nil, err
}
stories[i] = story
}
log.Println("Fetching HN top stories ... OK")
// Can't return [30]Story as []Story so we copy the array
return stories[:], nil
}
func FetchStoryById(id int) (Story, error) {
log.Printf("Fetching HN story (id=%d) ...\n", id)
url := fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id)
resp, err := http.Get(url)
if err != nil {
err = fmt.Errorf("error fetching HN story (id=%d): %w", id, err)
return Story{}, err
}
defer resp.Body.Close()
var story Story
err = json.NewDecoder(resp.Body).Decode(&story)
if err != nil {
err := fmt.Errorf("error decoding HN story JSON (id=%d): %w", id, err)
return Story{}, err
}
log.Printf("Fetching HN story (id=%d) ... OK\n", id)
return story, nil
}
func ParseHackerNewsLink(link string) (int, error) {
match := HackerNewsLinkRegexp.FindStringSubmatch(link)
if len(match) == 0 {
return -1, errors.New("input is not a hacker news link")
}
id, err := strconv.Atoi(match[1])
if err != nil {
return -1, errors.New("integer conversion to string failed")
}
return id, nil
}
func HackerNewsUserLink(user string) string {
return fmt.Sprintf("%s/user?id=%s", HackerNewsUrl, user)
}
func HackerNewsItemLink(id int) string {
return fmt.Sprintf("%s/item?id=%d", HackerNewsUrl, id)
}

105
hn/hn.go Normal file
View File

@ -0,0 +1,105 @@
package hn
import (
"encoding/json"
"errors"
"fmt"
"log"
"net/http"
"regexp"
"strconv"
)
type Item struct {
ID int
By string // username of author
Time int // UNIX timestamp
Descendants int // number of comments
Kids []int
Score int
Title string
Url string
}
var (
hnUrl = "https://news.ycombinator.com"
hnFirebaseUrl = "https://hacker-news.firebaseio.com/v0"
hnLinkRegexp = regexp.MustCompile(`(?:https?:\/\/)?news\.ycombinator\.com\/item\?id=([0-9]+)`)
)
func FetchTopItems() ([]Item, error) {
log.Println("[hn] fetch top items ...")
// API docs: https://github.com/HackerNews/API
url := fmt.Sprintf("%s/topstories.json", hnFirebaseUrl)
resp, err := http.Get(url)
if err != nil {
return nil, fmt.Errorf("error fetching HN top stories %w:", err)
}
defer resp.Body.Close()
var ids []int
err = json.NewDecoder(resp.Body).Decode(&ids)
if err != nil {
return nil, fmt.Errorf("error decoding HN top stories JSON: %w", err)
}
// we are only interested in the first page of top stories
const limit = 30
ids = ids[:limit]
var stories [limit]Item
for i, id := range ids {
var item Item
err := FetchItemById(id, &item)
if err != nil {
return nil, err
}
stories[i] = item
}
log.Println("[hn] fetch top items ... OK")
// Can't return [30]Item as []Item so we copy the array
return stories[:], nil
}
func FetchItemById(id int, hnItem *Item) error {
// log.Printf("[hn] fetch HN item %d ...\n", id)
url := fmt.Sprintf("https://hacker-news.firebaseio.com/v0/item/%d.json", id)
resp, err := http.Get(url)
if err != nil {
err = fmt.Errorf("error fetching HN item %d: %w", id, err)
return err
}
defer resp.Body.Close()
err = json.NewDecoder(resp.Body).Decode(&hnItem)
if err != nil {
err := fmt.Errorf("error decoding JSON for HN item %d: %w", id, err)
return err
}
// log.Printf("[hn] fetch HN item %d ... OK\n", id)
return nil
}
func ParseLink(link string) (int, error) {
match := hnLinkRegexp.FindStringSubmatch(link)
if len(match) == 0 {
return -1, errors.New("not a hacker news link")
}
id, err := strconv.Atoi(match[1])
if err != nil {
return -1, errors.New("integer conversion to string failed")
}
return id, nil
}
func UserLink(user string) string {
return fmt.Sprintf("%s/user?id=%s", hnUrl, user)
}
func ItemLink(id int) string {
return fmt.Sprintf("%s/item?id=%d", hnUrl, id)
}

44
main.go
View File

@ -5,33 +5,43 @@ import (
"log"
"time"
"github.com/ekzyis/sn-goapi"
"github.com/ekzyis/hnbot/db"
"github.com/ekzyis/hnbot/hn"
sn "github.com/ekzyis/hnbot/sn"
"github.com/joho/godotenv"
)
func SyncStories() {
func SyncHnItemsToDb() {
for {
now := time.Now()
dur := now.Truncate(time.Minute).Add(time.Minute).Sub(now)
log.Println("[hn] sleeping for", dur.Round(time.Second))
time.Sleep(dur)
stories, err := FetchHackerNewsTopStories()
stories, err := hn.FetchTopItems()
if err != nil {
SendErrorToDiscord(err)
log.Println(err)
continue
}
if err := SaveStories(&stories); err != nil {
SendErrorToDiscord(err)
if err := db.SaveHnItems(&stories); err != nil {
log.Println(err)
continue
}
}
}
func main() {
go SyncStories()
if err := godotenv.Load(); err != nil {
log.Fatal(err)
}
// fetch HN front page every minute in the background and store state in db
go SyncHnItemsToDb()
// check every 15 minutes if there is now a HN item that is worth posting to SN
for {
var (
filtered *[]Story
filtered *[]hn.Item
err error
)
@ -40,26 +50,26 @@ func main() {
log.Println("[sn] sleeping for", dur.Round(time.Second))
time.Sleep(dur)
if filtered, err = CurateContentForStackerNews(); err != nil {
SendErrorToDiscord(err)
if filtered, err = sn.CurateContent(); err != nil {
log.Println(err)
continue
}
for _, story := range *filtered {
_, err := PostStoryToStackerNews(&story, PostStoryOptions{SkipDupes: false})
log.Printf("[sn] found %d item(s) to post\n", len(*filtered))
for _, item := range *filtered {
_, err := sn.Post(&item, sn.PostOptions{SkipDupes: false})
if err != nil {
var dupesErr *sn.DupesError
if errors.As(err, &dupesErr) {
// SendDupesErrorToDiscord(story.ID, dupesErr)
log.Println(dupesErr)
// save dupe in db to prevent retries
parentId := dupesErr.Dupes[0].Id
if err := SaveSnItem(parentId, story.ID); err != nil {
SendErrorToDiscord(err)
if err := db.SaveSnItem(parentId, item.ID); err != nil {
log.Println(err)
}
continue
}
SendErrorToDiscord(err)
log.Println(err)
continue
}
}

63
plot.py
View File

@ -1,63 +0,0 @@
#!/usr/bin/env python3
import pandas as pd
import matplotlib.pyplot as plt
import matplotlib.dates as mdates
from datetime import timedelta, datetime
# this script loads data from a csv file
# with headers id, created_at, time, title, url, author, ndescendants, score, rank
# and then saves a plot with score, ndescendants and rank for each id
# load data from csv file
df = pd.read_csv('hacker_news.csv', index_col='created_at')
# group pandas dataframe by id
grouped = df.groupby(['id'])
# create one chart per id and plot score, ndescendants and rank in each chart
for [hn_id], group in grouped:
# sort group by created_at ascending
group = group.sort_values(by='created_at', ascending=True)
# this is the time when the item was created on HN
item_created_at = datetime.utcfromtimestamp(group['time'].values[0])
# use relative time for x axis
def date_to_relative(d1):
date_fmt = '%Y-%m-%d %H:%M:%S'
current = datetime.strptime(d1, date_fmt)
return (current - item_created_at) / timedelta(hours=1)
group.index = group.index.map(date_to_relative)
# title generation
hn_item_title = group['title'].values[0]
hn_item_url = group['url'].values[0]
hn_item_link = f'https://news.ycombinator.com/item?id={hn_id}'
plot_title = f'{hn_item_title}\n{hn_item_url}\n{hn_item_link}'
fig, ax1 = plt.subplots(figsize=(10, 5))
ax1.set_title(plot_title)
ax1.set_xlabel('hours')
ax1.set_ylabel('score, comments')
ax1.plot(group['score'], label='score', color='blue')
ax1.plot(group['ndescendants'], label='comments', color='orange')
ax1.legend()
# show every 50th date
# TODO: do something more clever here
plt.xticks(group.index[::50], rotation=45)
ax2 = ax1.twinx()
ax2.set_ylabel('rank')
ax2.set_ylim(1, 30)
ax2.plot(group['rank'], label='rank', color='green')
ax2.legend(loc='upper right')
plt.tight_layout()
plt.savefig(f'hn_{hn_id}.png')
plt.close()
print(f'Saved hn_{hn_id}.png')

134
sn.go
View File

@ -1,134 +0,0 @@
package main
import (
"database/sql"
"fmt"
"log"
"time"
"github.com/bwmarrin/discordgo"
"github.com/dustin/go-humanize"
"github.com/ekzyis/sn-goapi"
)
func CurateContentForStackerNews() (*[]Story, error) {
var (
rows *sql.Rows
err error
)
if rows, err = db.Query(`
SELECT t.id, time, title, url, author, score, ndescendants
FROM (
SELECT id, MIN(created_at) AS start, MAX(created_at) AS end
FROM hn_items
WHERE rank = 1 AND id NOT IN (SELECT hn_id FROM sn_items) AND length(title) >= 5
GROUP BY id
HAVING unixepoch(end) - unixepoch(start) >= 3600 ORDER BY time ASC
LIMIT 1
) t JOIN hn_items ON t.id = hn_items.id AND t.end = hn_items.created_at;
`); err != nil {
err = fmt.Errorf("error querying hn_items: %w", err)
return nil, err
}
defer rows.Close()
var stories []Story
for rows.Next() {
var story Story
if err = rows.Scan(&story.ID, &story.Time, &story.Title, &story.Url, &story.By, &story.Score, &story.Descendants); err != nil {
err = fmt.Errorf("error scanning hn_items: %w", err)
return nil, err
}
stories = append(stories, story)
}
if err = rows.Err(); err != nil {
err = fmt.Errorf("error iterating hn_items: %w", err)
return nil, err
}
return &stories, nil
}
type PostStoryOptions struct {
SkipDupes bool
}
func PostStoryToStackerNews(story *Story, options PostStoryOptions) (int, error) {
url := story.Url
if url == "" {
url = HackerNewsItemLink(story.ID)
}
log.Printf("Posting to SN (url=%s) ...\n", url)
if !options.SkipDupes {
dupes, err := sn.Dupes(url)
if err != nil {
return -1, err
}
if len(*dupes) > 0 {
return -1, &sn.DupesError{Url: url, Dupes: *dupes}
}
}
title := story.Title
if len(title) > 80 {
title = title[0:80]
}
parentId, err := sn.PostLink(url, title, "tech")
if err != nil {
return -1, fmt.Errorf("error posting link: %w", err)
}
log.Printf("Posting to SN (url=%s) ... OK \n", url)
if err := SaveSnItem(parentId, story.ID); err != nil {
return -1, err
}
SendStackerNewsEmbedToDiscord(story.Title, parentId)
comment := fmt.Sprintf(
"This link was posted by [%s](%s) %s on [HN](%s). It received %d points and %d comments.",
story.By,
HackerNewsUserLink(story.By),
humanize.Time(time.Unix(int64(story.Time), 0)),
HackerNewsItemLink(story.ID),
story.Score, story.Descendants,
) + fmt.Sprintf("\n\nhttps://files.ekzyis.com/public/hn/hn_%d.png", story.ID)
if _, err := sn.CreateComment(parentId, comment); err != nil {
return -1, fmt.Errorf("error posting comment :%w", err)
}
return parentId, nil
}
func SendStackerNewsEmbedToDiscord(title string, id int) {
Timestamp := time.Now().Format(time.RFC3339)
url := sn.FormatLink(id)
color := 0xffc107
embed := discordgo.MessageEmbed{
Title: title,
URL: url,
Color: color,
Footer: &discordgo.MessageEmbedFooter{
Text: "Stacker News",
IconURL: "https://stacker.news/favicon.png",
},
Timestamp: Timestamp,
}
SendEmbedToDiscord(&embed)
}
func SendNotificationsEmbedToDiscord() {
Timestamp := time.Now().Format(time.RFC3339)
color := 0xffc107
embed := discordgo.MessageEmbed{
Title: "new notifications",
URL: "https://stacker.news/hn/posts",
Color: color,
Footer: &discordgo.MessageEmbedFooter{
Text: "Stacker News",
IconURL: "https://stacker.news/favicon-notify.png",
},
Timestamp: Timestamp,
}
SendEmbedToDiscord(&embed)
}

102
sn/sn.go Normal file
View File

@ -0,0 +1,102 @@
package sn
import (
"database/sql"
"fmt"
"log"
"time"
"github.com/dustin/go-humanize"
"github.com/ekzyis/hnbot/db"
"github.com/ekzyis/hnbot/hn"
sn "github.com/ekzyis/snappy"
)
type DupesError = sn.DupesError
func CurateContent() (*[]hn.Item, error) {
var (
rows *sql.Rows
err error
)
if rows, err = db.Query(`
SELECT t.id, time, title, url, author, score, ndescendants
FROM (
SELECT id, MIN(created_at) AS start, MAX(created_at) AS end
FROM hn_items
WHERE rank = 1 AND id NOT IN (SELECT hn_id FROM sn_items) AND length(title) >= 5
GROUP BY id
HAVING unixepoch(end) - unixepoch(start) >= 3600
ORDER BY time ASC
LIMIT 1
) t JOIN hn_items ON t.id = hn_items.id AND t.end = hn_items.created_at;
`); err != nil {
err = fmt.Errorf("error querying hn_items: %w", err)
return nil, err
}
defer rows.Close()
var items []hn.Item
for rows.Next() {
var item hn.Item
if err = rows.Scan(&item.ID, &item.Time, &item.Title, &item.Url, &item.By, &item.Score, &item.Descendants); err != nil {
err = fmt.Errorf("error scanning hn_items: %w", err)
return nil, err
}
items = append(items, item)
}
if err = rows.Err(); err != nil {
err = fmt.Errorf("error iterating hn_items: %w", err)
return nil, err
}
return &items, nil
}
type PostOptions struct {
SkipDupes bool
}
func Post(item *hn.Item, options PostOptions) (int, error) {
c := sn.NewClient()
url := item.Url
if url == "" {
url = hn.ItemLink(item.ID)
}
log.Printf("post to SN: %s ...\n", url)
if !options.SkipDupes {
dupes, err := c.Dupes(url)
if err != nil {
return -1, err
}
if len(*dupes) > 0 {
return -1, &sn.DupesError{Url: url, Dupes: *dupes}
}
}
title := item.Title
if len(title) > 80 {
title = title[0:80]
}
comment := fmt.Sprintf(
"This link was posted by [%s](%s) %s on [HN](%s). It received %d points and %d comments.",
item.By,
hn.UserLink(item.By),
humanize.Time(time.Unix(int64(item.Time), 0)),
hn.ItemLink(item.ID),
item.Score, item.Descendants,
)
parentId, err := c.PostLink(url, title, comment, "tech")
if err != nil {
return -1, fmt.Errorf("error posting link: %w", err)
}
log.Printf("post to SN: %s ... OK \n", url)
if err := db.SaveSnItem(parentId, item.ID); err != nil {
return -1, err
}
return parentId, nil
}