Compare commits
5 Commits
ee95aa89bd
...
3d35aa5e19
Author | SHA1 | Date | |
---|---|---|---|
3d35aa5e19 | |||
8e28ee4691 | |||
35a4112c22 | |||
24909f5d88 | |||
3bf5c8baba |
@ -1,3 +0,0 @@
|
||||
SN_AUTH_COOKIE=
|
||||
DISCORD_TOKEN=
|
||||
DISCORD_CHANNEL_ID=
|
7
.gitignore
vendored
7
.gitignore
vendored
@ -1,12 +1,5 @@
|
||||
.env
|
||||
.vscode
|
||||
# python virtual environment
|
||||
venv
|
||||
|
||||
# go executable
|
||||
hnbot
|
||||
hnbot.sqlite3
|
||||
|
||||
*.csv
|
||||
*.png
|
||||
|
||||
|
@ -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
|
||||
|
188
discord.go
188
discord.go
@ -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
4
go.mod
@ -3,15 +3,15 @@ module gitlab.com/ekzyis/hnbot
|
||||
go 1.20
|
||||
|
||||
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/ekzyis/sn-goapi v0.3.3
|
||||
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/ekzyis/snappy v0.4.2-0.20240407032738-17878b2f32ba
|
||||
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
|
||||
|
8
go.sum
8
go.sum
@ -1,9 +1,9 @@
|
||||
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/bwmarrin/discordgo v0.28.1 h1:gXsuo2GBO7NbR6uqmrrBDplPUx2T3nzu775q/Rd1aG4=
|
||||
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/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/ekzyis/snappy v0.4.2-0.20240407032738-17878b2f32ba h1:myhgV9ECBhDPx0juj+td6U/5PIf0Bvj4x1hMBQFCZ1Q=
|
||||
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/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
|
@ -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
BIN
hnbot.sqlite3
Normal file
Binary file not shown.
12
main.go
12
main.go
@ -5,7 +5,7 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/ekzyis/sn-goapi"
|
||||
sn "github.com/ekzyis/snappy"
|
||||
)
|
||||
|
||||
func SyncStories() {
|
||||
@ -17,11 +17,11 @@ func SyncStories() {
|
||||
|
||||
stories, err := FetchHackerNewsTopStories()
|
||||
if err != nil {
|
||||
SendErrorToDiscord(err)
|
||||
log.Println(err)
|
||||
continue
|
||||
}
|
||||
if err := SaveStories(&stories); err != nil {
|
||||
SendErrorToDiscord(err)
|
||||
log.Println(err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
@ -41,7 +41,7 @@ func main() {
|
||||
time.Sleep(dur)
|
||||
|
||||
if filtered, err = CurateContentForStackerNews(); err != nil {
|
||||
SendErrorToDiscord(err)
|
||||
log.Println(err)
|
||||
continue
|
||||
}
|
||||
|
||||
@ -55,11 +55,11 @@ func main() {
|
||||
// save dupe in db to prevent retries
|
||||
parentId := dupesErr.Dupes[0].Id
|
||||
if err := SaveSnItem(parentId, story.ID); err != nil {
|
||||
SendErrorToDiscord(err)
|
||||
log.Println(err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
SendErrorToDiscord(err)
|
||||
log.Println(err)
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
63
plot.py
63
plot.py
@ -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
63
sn.go
@ -6,9 +6,8 @@ import (
|
||||
"log"
|
||||
"time"
|
||||
|
||||
"github.com/bwmarrin/discordgo"
|
||||
"github.com/dustin/go-humanize"
|
||||
"github.com/ekzyis/sn-goapi"
|
||||
sn "github.com/ekzyis/snappy"
|
||||
)
|
||||
|
||||
func CurateContentForStackerNews() (*[]Story, error) {
|
||||
@ -54,6 +53,7 @@ type PostStoryOptions struct {
|
||||
}
|
||||
|
||||
func PostStoryToStackerNews(story *Story, options PostStoryOptions) (int, error) {
|
||||
c := sn.NewClient()
|
||||
url := story.Url
|
||||
if url == "" {
|
||||
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)
|
||||
|
||||
if !options.SkipDupes {
|
||||
dupes, err := sn.Dupes(url)
|
||||
dupes, err := c.Dupes(url)
|
||||
if err != nil {
|
||||
return -1, err
|
||||
}
|
||||
@ -75,7 +75,16 @@ func PostStoryToStackerNews(story *Story, options PostStoryOptions) (int, error)
|
||||
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 {
|
||||
return -1, fmt.Errorf("error posting link: %w", err)
|
||||
}
|
||||
@ -85,51 +94,5 @@ func PostStoryToStackerNews(story *Story, options PostStoryOptions) (int, error)
|
||||
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
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user