Compare commits

..

5 Commits

Author SHA1 Message Date
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
11 changed files with 29 additions and 338 deletions

View File

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

7
.gitignore vendored
View File

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

View File

@ -2,10 +2,10 @@
> Hello, I am a bot posting top stories from HN. > 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. 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

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)
}

4
go.mod
View File

@ -3,15 +3,15 @@ module gitlab.com/ekzyis/hnbot
go 1.20 go 1.20
require ( require (
github.com/bwmarrin/discordgo v0.27.1 github.com/bwmarrin/discordgo v0.28.1
github.com/dustin/go-humanize v1.0.1 github.com/dustin/go-humanize v1.0.1
github.com/ekzyis/sn-goapi v0.3.3
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/mattn/go-sqlite3 v1.14.22 github.com/mattn/go-sqlite3 v1.14.22
github.com/namsral/flag v1.7.4-pre github.com/namsral/flag v1.7.4-pre
) )
require ( require (
github.com/ekzyis/snappy v0.4.2-0.20240407032738-17878b2f32ba
github.com/gorilla/websocket v1.4.2 // indirect github.com/gorilla/websocket v1.4.2 // indirect
golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect golang.org/x/crypto v0.0.0-20210421170649-83a5a9bb288b // indirect
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect golang.org/x/sys v0.0.0-20201119102817-f84b799fce68 // indirect

8
go.sum
View File

@ -1,9 +1,9 @@
github.com/bwmarrin/discordgo v0.27.1 h1:ib9AIc/dom1E/fSIulrBwnez0CToJE113ZGt4HoliGY= github.com/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
github.com/bwmarrin/discordgo v0.27.1/go.mod h1:NJZpH+1AfhIcyQsPeuBKsUtYrRnjkyu0kIVMCHkZtRY= github.com/bwmarrin/discordgo v0.28.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 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= 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/snappy v0.4.2-0.20240407032738-17878b2f32ba h1:myhgV9ECBhDPx0juj+td6U/5PIf0Bvj4x1hMBQFCZ1Q=
github.com/ekzyis/sn-goapi v0.3.3/go.mod h1:FObbYr/NXgnXNWU+EwiWKoWQy+wAaRS6AoW3NgsJ/Oo= github.com/ekzyis/snappy v0.4.2-0.20240407032738-17878b2f32ba/go.mod h1:BxJwdGlCwUw0Q5pQzBr59weAIS6pkVdivBBaZkkWTSo=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc= 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/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=

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;

BIN
hnbot.sqlite3 Normal file

Binary file not shown.

12
main.go
View File

@ -5,7 +5,7 @@ import (
"log" "log"
"time" "time"
"github.com/ekzyis/sn-goapi" sn "github.com/ekzyis/snappy"
) )
func SyncStories() { func SyncStories() {
@ -17,11 +17,11 @@ func SyncStories() {
stories, err := FetchHackerNewsTopStories() stories, err := FetchHackerNewsTopStories()
if err != nil { if err != nil {
SendErrorToDiscord(err) log.Println(err)
continue continue
} }
if err := SaveStories(&stories); err != nil { if err := SaveStories(&stories); err != nil {
SendErrorToDiscord(err) log.Println(err)
continue continue
} }
} }
@ -41,7 +41,7 @@ func main() {
time.Sleep(dur) time.Sleep(dur)
if filtered, err = CurateContentForStackerNews(); err != nil { if filtered, err = CurateContentForStackerNews(); err != nil {
SendErrorToDiscord(err) log.Println(err)
continue continue
} }
@ -55,11 +55,11 @@ func main() {
// save dupe in db to prevent retries // save dupe in db to prevent retries
parentId := dupesErr.Dupes[0].Id parentId := dupesErr.Dupes[0].Id
if err := SaveSnItem(parentId, story.ID); err != nil { if err := SaveSnItem(parentId, story.ID); err != nil {
SendErrorToDiscord(err) log.Println(err)
} }
continue continue
} }
SendErrorToDiscord(err) log.Println(err)
continue 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')

63
sn.go
View File

@ -6,9 +6,8 @@ import (
"log" "log"
"time" "time"
"github.com/bwmarrin/discordgo"
"github.com/dustin/go-humanize" "github.com/dustin/go-humanize"
"github.com/ekzyis/sn-goapi" sn "github.com/ekzyis/snappy"
) )
func CurateContentForStackerNews() (*[]Story, error) { func CurateContentForStackerNews() (*[]Story, error) {
@ -54,6 +53,7 @@ type PostStoryOptions struct {
} }
func PostStoryToStackerNews(story *Story, options PostStoryOptions) (int, error) { func PostStoryToStackerNews(story *Story, options PostStoryOptions) (int, error) {
c := sn.NewClient()
url := story.Url url := story.Url
if url == "" { if url == "" {
url = HackerNewsItemLink(story.ID) url = HackerNewsItemLink(story.ID)
@ -61,7 +61,7 @@ func PostStoryToStackerNews(story *Story, options PostStoryOptions) (int, error)
log.Printf("Posting to SN (url=%s) ...\n", url) log.Printf("Posting to SN (url=%s) ...\n", url)
if !options.SkipDupes { if !options.SkipDupes {
dupes, err := sn.Dupes(url) dupes, err := c.Dupes(url)
if err != nil { if err != nil {
return -1, err return -1, err
} }
@ -75,7 +75,16 @@ func PostStoryToStackerNews(story *Story, options PostStoryOptions) (int, error)
title = title[0:80] title = title[0:80]
} }
parentId, err := sn.PostLink(url, title, "tech") 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,
)
parentId, err := c.PostLink(url, title, comment, "tech")
if err != nil { if err != nil {
return -1, fmt.Errorf("error posting link: %w", err) return -1, fmt.Errorf("error posting link: %w", err)
} }
@ -85,51 +94,5 @@ func PostStoryToStackerNews(story *Story, options PostStoryOptions) (int, error)
return -1, err 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,
)
if _, err := sn.CreateComment(parentId, comment); err != nil {
return -1, fmt.Errorf("error posting comment :%w", err)
}
return parentId, nil 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)
}