2019-07-15 16:46:24 +00:00
|
|
|
package webshit
|
|
|
|
|
|
|
|
import (
|
|
|
|
"bytes"
|
|
|
|
"fmt"
|
|
|
|
"github.com/PaulRosset/go-hacknews"
|
|
|
|
"github.com/PuerkitoBio/goquery"
|
|
|
|
"github.com/jmoiron/sqlx"
|
|
|
|
"github.com/mmcdole/gofeed"
|
2019-07-15 17:39:40 +00:00
|
|
|
"github.com/rs/zerolog/log"
|
|
|
|
"net/http"
|
2019-07-15 16:46:24 +00:00
|
|
|
"net/url"
|
2019-07-15 17:39:40 +00:00
|
|
|
"strings"
|
2019-07-15 18:57:23 +00:00
|
|
|
"time"
|
2019-07-15 16:46:24 +00:00
|
|
|
)
|
|
|
|
|
2019-07-16 02:00:19 +00:00
|
|
|
type Config struct {
|
|
|
|
HNFeed string
|
|
|
|
HNLimit int
|
|
|
|
BalanceReferesh int
|
|
|
|
}
|
|
|
|
|
|
|
|
var DefaultConfig = Config{
|
|
|
|
HNFeed: "topstories",
|
|
|
|
HNLimit: 10,
|
|
|
|
BalanceReferesh: 100,
|
|
|
|
}
|
|
|
|
|
2019-07-15 16:46:24 +00:00
|
|
|
type Webshit struct {
|
2019-07-16 02:00:19 +00:00
|
|
|
db *sqlx.DB
|
|
|
|
config Config
|
2019-07-15 16:46:24 +00:00
|
|
|
}
|
|
|
|
|
2019-07-15 17:39:40 +00:00
|
|
|
type Story struct {
|
|
|
|
Title string
|
|
|
|
URL string
|
|
|
|
}
|
|
|
|
|
2019-07-16 02:00:19 +00:00
|
|
|
type Stories []Story
|
|
|
|
|
|
|
|
func (s Stories) Titles() string {
|
|
|
|
out := ""
|
|
|
|
for i, v := range s {
|
|
|
|
if i > 0 {
|
|
|
|
out += ", "
|
|
|
|
}
|
|
|
|
out += v.Title
|
|
|
|
}
|
|
|
|
return out
|
|
|
|
}
|
|
|
|
|
2019-07-15 17:39:40 +00:00
|
|
|
type Bid struct {
|
2019-07-15 20:55:35 +00:00
|
|
|
ID int
|
|
|
|
User string
|
|
|
|
Title string
|
|
|
|
URL string
|
|
|
|
Bid int
|
|
|
|
Placed int64
|
|
|
|
}
|
|
|
|
|
|
|
|
func (b Bid) PlacedParsed() time.Time {
|
|
|
|
return time.Unix(b.Placed, 0)
|
|
|
|
}
|
|
|
|
|
|
|
|
type Balance struct {
|
|
|
|
User string
|
|
|
|
Balance int
|
|
|
|
Score int
|
2019-07-15 17:39:40 +00:00
|
|
|
}
|
|
|
|
|
2019-07-15 18:57:23 +00:00
|
|
|
type WeeklyResult struct {
|
2019-07-16 02:00:19 +00:00
|
|
|
User string
|
|
|
|
Won int
|
|
|
|
WinningArticles Stories
|
|
|
|
LosingArticles Stories
|
|
|
|
Score int
|
2019-07-15 18:57:23 +00:00
|
|
|
}
|
|
|
|
|
2019-07-15 16:46:24 +00:00
|
|
|
func New(db *sqlx.DB) *Webshit {
|
2019-07-16 02:00:19 +00:00
|
|
|
return NewConfig(db, DefaultConfig)
|
|
|
|
}
|
|
|
|
|
|
|
|
func NewConfig(db *sqlx.DB, cfg Config) *Webshit {
|
|
|
|
w := &Webshit{db: db, config: cfg}
|
2019-07-15 16:46:24 +00:00
|
|
|
w.setup()
|
|
|
|
return w
|
|
|
|
}
|
|
|
|
|
|
|
|
// setup will create any necessary SQL tables and populate them with minimal data
|
|
|
|
func (w *Webshit) setup() {
|
2019-07-15 20:55:35 +00:00
|
|
|
w.db.MustExec(`create table if not exists webshit_bids (
|
|
|
|
id integer primary key autoincrement,
|
2019-07-15 17:39:40 +00:00
|
|
|
user string,
|
|
|
|
title string,
|
|
|
|
url string,
|
2019-07-15 18:57:23 +00:00
|
|
|
bid integer,
|
2019-07-15 20:55:35 +00:00
|
|
|
placed integer
|
|
|
|
)`)
|
|
|
|
w.db.MustExec(`create table if not exists webshit_balances (
|
2019-07-15 17:39:40 +00:00
|
|
|
user string primary key,
|
|
|
|
balance int,
|
|
|
|
score int
|
2019-07-15 20:55:35 +00:00
|
|
|
)`)
|
2019-07-15 16:46:24 +00:00
|
|
|
}
|
|
|
|
|
2019-07-15 20:55:35 +00:00
|
|
|
func (w *Webshit) Check() ([]WeeklyResult, error) {
|
2019-07-15 18:57:23 +00:00
|
|
|
stories, published, err := w.GetWeekly()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
var bids []Bid
|
2019-07-15 20:55:35 +00:00
|
|
|
if err = w.db.Select(&bids, `select user,title,url,bid from webshit_bids where placed < ?`,
|
2019-07-15 18:57:23 +00:00
|
|
|
published.Unix()); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Assuming no bids earlier than the weekly means there hasn't been a new weekly
|
|
|
|
if len(bids) == 0 {
|
2019-07-15 20:55:35 +00:00
|
|
|
return nil, fmt.Errorf("there are no bids against the current ngate post")
|
2019-07-15 18:57:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
storyMap := map[string]Story{}
|
|
|
|
for _, s := range stories {
|
2019-08-08 14:13:24 +00:00
|
|
|
u, err := url.Parse(s.URL)
|
|
|
|
if err != nil {
|
|
|
|
log.Error().Err(err).Msg("couldn't parse URL")
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
id := u.Query().Get("id")
|
|
|
|
storyMap[id] = s
|
2019-07-15 18:57:23 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
wr := w.checkBids(bids, storyMap)
|
|
|
|
|
|
|
|
// Update all balance scores in a tx
|
|
|
|
if err := w.updateScores(wr); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Delete all those bids
|
2019-07-15 20:55:35 +00:00
|
|
|
if _, err = w.db.Exec(`delete from webshit_bids where placed < ?`,
|
2019-07-15 18:57:23 +00:00
|
|
|
published.Unix()); err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Set all balances to 100
|
2019-07-16 02:00:19 +00:00
|
|
|
if _, err = w.db.Exec(`update webshit_balances set balance=?`,
|
|
|
|
w.config.BalanceReferesh); err != nil {
|
2019-07-15 18:57:23 +00:00
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
|
|
|
|
return wr, nil
|
|
|
|
}
|
|
|
|
|
2019-07-15 20:55:35 +00:00
|
|
|
func (w *Webshit) checkBids(bids []Bid, storyMap map[string]Story) []WeeklyResult {
|
2019-08-11 19:05:15 +00:00
|
|
|
|
|
|
|
var wins []Bid
|
|
|
|
total, totalWinning := 0, 0
|
2019-07-15 18:57:23 +00:00
|
|
|
wr := map[string]WeeklyResult{}
|
2019-08-11 19:05:15 +00:00
|
|
|
|
2019-07-15 18:57:23 +00:00
|
|
|
for _, b := range bids {
|
2019-07-15 20:55:35 +00:00
|
|
|
score := w.GetScore(b.User)
|
2019-07-16 02:00:19 +00:00
|
|
|
if _, ok := wr[b.User]; !ok {
|
2019-07-15 18:57:23 +00:00
|
|
|
wr[b.User] = WeeklyResult{
|
2019-07-15 20:55:35 +00:00
|
|
|
User: b.User,
|
2019-07-16 02:00:19 +00:00
|
|
|
Score: score,
|
2019-07-15 18:57:23 +00:00
|
|
|
}
|
2019-07-16 02:00:19 +00:00
|
|
|
}
|
|
|
|
rec := wr[b.User]
|
|
|
|
|
2019-08-08 14:13:24 +00:00
|
|
|
u, err := url.Parse(b.URL)
|
|
|
|
if err != nil {
|
|
|
|
log.Error().Err(err).Msg("couldn't parse URL")
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
id := u.Query().Get("id")
|
|
|
|
|
|
|
|
if s, ok := storyMap[id]; ok {
|
2019-08-11 19:05:15 +00:00
|
|
|
wins = append(wins, b)
|
2019-07-16 02:00:19 +00:00
|
|
|
rec.WinningArticles = append(rec.WinningArticles, s)
|
2019-08-11 19:05:15 +00:00
|
|
|
totalWinning += b.Bid
|
2019-07-15 18:57:23 +00:00
|
|
|
} else {
|
2019-08-11 19:05:15 +00:00
|
|
|
rec.LosingArticles = append(rec.LosingArticles, Story{b.Title, b.URL})
|
2019-07-15 18:57:23 +00:00
|
|
|
}
|
2019-08-11 19:05:15 +00:00
|
|
|
total += b.Bid
|
2019-07-16 02:00:19 +00:00
|
|
|
wr[b.User] = rec
|
2019-07-15 18:57:23 +00:00
|
|
|
}
|
2019-08-11 19:05:15 +00:00
|
|
|
|
|
|
|
for _, b := range wins {
|
|
|
|
payout := b.Bid / totalWinning * total
|
|
|
|
rec := wr[b.User]
|
|
|
|
rec.Won += payout
|
|
|
|
rec.Score += payout
|
|
|
|
wr[b.User] = rec
|
|
|
|
}
|
|
|
|
|
2019-07-15 20:55:35 +00:00
|
|
|
return wrMapToSlice(wr)
|
2019-07-15 18:57:23 +00:00
|
|
|
}
|
|
|
|
|
2019-07-15 16:46:24 +00:00
|
|
|
// GetHeadlines will return the current possible news headlines for bidding
|
2019-07-15 17:39:40 +00:00
|
|
|
func (w *Webshit) GetHeadlines() ([]Story, error) {
|
2019-07-16 02:00:19 +00:00
|
|
|
news := hacknews.Initializer{Story: w.config.HNFeed, NbPosts: w.config.HNLimit}
|
2019-07-15 16:46:24 +00:00
|
|
|
ids, err := news.GetCodesStory()
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
posts, err := news.GetPostStory(ids)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
2019-07-15 17:39:40 +00:00
|
|
|
var stories []Story
|
|
|
|
for _, p := range posts {
|
|
|
|
stories = append(stories, Story{
|
|
|
|
Title: p.Title,
|
|
|
|
URL: p.Url,
|
|
|
|
})
|
|
|
|
}
|
|
|
|
return stories, nil
|
2019-07-15 16:46:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// GetWeekly will return the headlines in the last webshit weekly report
|
2019-07-15 18:57:23 +00:00
|
|
|
func (w *Webshit) GetWeekly() ([]Story, *time.Time, error) {
|
2019-07-15 16:46:24 +00:00
|
|
|
fp := gofeed.NewParser()
|
|
|
|
feed, err := fp.ParseURL("http://n-gate.com/hackernews/index.rss")
|
|
|
|
if err != nil {
|
2019-07-15 18:57:23 +00:00
|
|
|
return nil, nil, err
|
2019-07-15 16:46:24 +00:00
|
|
|
}
|
|
|
|
if len(feed.Items) <= 0 {
|
2019-07-15 18:57:23 +00:00
|
|
|
return nil, nil, fmt.Errorf("no webshit weekly found")
|
2019-07-15 16:46:24 +00:00
|
|
|
}
|
|
|
|
|
2019-07-15 20:55:35 +00:00
|
|
|
published := feed.Items[0].PublishedParsed
|
2019-07-15 18:57:23 +00:00
|
|
|
|
2019-07-15 16:46:24 +00:00
|
|
|
buf := bytes.NewBufferString(feed.Items[0].Description)
|
|
|
|
doc, err := goquery.NewDocumentFromReader(buf)
|
|
|
|
if err != nil {
|
2019-07-15 18:57:23 +00:00
|
|
|
return nil, nil, err
|
2019-07-15 16:46:24 +00:00
|
|
|
}
|
|
|
|
|
2019-07-15 18:57:23 +00:00
|
|
|
var items []Story
|
2019-07-15 16:46:24 +00:00
|
|
|
doc.Find(".storylink").Each(func(i int, s *goquery.Selection) {
|
2019-07-15 18:57:23 +00:00
|
|
|
story := Story{
|
|
|
|
Title: s.Find("a").Text(),
|
2019-07-16 19:49:35 +00:00
|
|
|
URL: s.SiblingsFiltered(".small").First().Find("a").AttrOr("href", ""),
|
2019-07-15 18:57:23 +00:00
|
|
|
}
|
|
|
|
items = append(items, story)
|
2019-07-16 19:49:35 +00:00
|
|
|
log.Debug().
|
|
|
|
Str("URL", story.URL).
|
|
|
|
Str("Title", story.Title).
|
|
|
|
Msg("Parsed webshit story")
|
2019-07-15 16:46:24 +00:00
|
|
|
})
|
|
|
|
|
2019-07-15 18:57:23 +00:00
|
|
|
return items, published, nil
|
2019-07-15 16:46:24 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// GetBalances returns the current balance for all known users
|
|
|
|
// Any unknown user has a default balance on their first bid
|
2019-07-15 17:39:40 +00:00
|
|
|
func (w *Webshit) GetBalance(user string) int {
|
|
|
|
q := `select balance from webshit_balances where user=?`
|
|
|
|
var balance int
|
|
|
|
err := w.db.Get(&balance, q, user)
|
|
|
|
if err != nil {
|
|
|
|
return 100
|
|
|
|
}
|
|
|
|
return balance
|
2019-07-15 16:46:24 +00:00
|
|
|
}
|
|
|
|
|
2019-07-15 20:55:35 +00:00
|
|
|
func (w *Webshit) GetScore(user string) int {
|
|
|
|
q := `select score from webshit_balances where user=?`
|
|
|
|
var score int
|
|
|
|
err := w.db.Get(&score, q, user)
|
|
|
|
if err != nil {
|
|
|
|
return 0
|
|
|
|
}
|
|
|
|
return score
|
|
|
|
}
|
|
|
|
|
|
|
|
func (w *Webshit) GetAllBids() ([]Bid, error) {
|
|
|
|
var bids []Bid
|
|
|
|
err := w.db.Select(&bids, `select * from webshit_bids`)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return bids, nil
|
|
|
|
}
|
|
|
|
|
|
|
|
func (w *Webshit) GetAllBalances() ([]Balance, error) {
|
|
|
|
var balances []Balance
|
|
|
|
err := w.db.Select(&balances, `select * from webshit_balances`)
|
|
|
|
if err != nil {
|
|
|
|
return nil, err
|
|
|
|
}
|
|
|
|
return balances, nil
|
|
|
|
}
|
|
|
|
|
2019-07-15 16:46:24 +00:00
|
|
|
// Bid allows a user to place a bid on a particular story
|
2019-07-16 19:55:38 +00:00
|
|
|
func (w *Webshit) Bid(user string, amount int, URL string) (Bid, error) {
|
2019-07-15 17:39:40 +00:00
|
|
|
bal := w.GetBalance(user)
|
2019-07-18 18:57:24 +00:00
|
|
|
if amount < 0 {
|
|
|
|
return Bid{}, fmt.Errorf("cannot bid less than 0")
|
|
|
|
}
|
2019-07-15 17:39:40 +00:00
|
|
|
if bal < amount {
|
2019-07-16 19:55:38 +00:00
|
|
|
return Bid{}, fmt.Errorf("cannot bid more than balance, %d", bal)
|
2019-07-15 17:39:40 +00:00
|
|
|
}
|
|
|
|
story, err := w.getStoryByURL(URL)
|
|
|
|
if err != nil {
|
2019-07-16 19:55:38 +00:00
|
|
|
return Bid{}, err
|
2019-07-15 17:39:40 +00:00
|
|
|
}
|
|
|
|
|
2019-07-16 19:55:38 +00:00
|
|
|
ts := time.Now().Unix()
|
|
|
|
|
2019-07-15 18:57:23 +00:00
|
|
|
tx := w.db.MustBegin()
|
2019-07-15 20:55:35 +00:00
|
|
|
_, err = tx.Exec(`insert into webshit_bids (user,title,url,bid,placed) values (?,?,?,?,?)`,
|
2019-07-16 19:55:38 +00:00
|
|
|
user, story.Title, story.URL, amount, ts)
|
2019-07-15 18:57:23 +00:00
|
|
|
if err != nil {
|
|
|
|
tx.Rollback()
|
2019-07-16 19:55:38 +00:00
|
|
|
return Bid{}, err
|
2019-07-15 18:57:23 +00:00
|
|
|
}
|
2019-07-15 20:55:35 +00:00
|
|
|
q := `insert into webshit_balances (user,balance,score) values (?,?,0)
|
|
|
|
on conflict(user) do update set balance=?`
|
|
|
|
_, err = tx.Exec(q, user, bal-amount, bal-amount)
|
2019-07-15 18:57:23 +00:00
|
|
|
if err != nil {
|
|
|
|
tx.Rollback()
|
2019-07-16 19:55:38 +00:00
|
|
|
return Bid{}, err
|
2019-07-15 18:57:23 +00:00
|
|
|
}
|
|
|
|
tx.Commit()
|
2019-07-15 17:39:40 +00:00
|
|
|
|
2019-07-16 19:55:38 +00:00
|
|
|
return Bid{
|
|
|
|
User: user,
|
|
|
|
Title: story.Title,
|
|
|
|
URL: story.URL,
|
|
|
|
Placed: ts,
|
|
|
|
}, err
|
2019-07-15 17:39:40 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
// getStoryByURL scrapes the URL for a title
|
|
|
|
func (w *Webshit) getStoryByURL(URL string) (Story, error) {
|
|
|
|
u, err := url.Parse(URL)
|
|
|
|
if err != nil {
|
|
|
|
return Story{}, err
|
|
|
|
}
|
|
|
|
if u.Host != "news.ycombinator.com" {
|
|
|
|
return Story{}, fmt.Errorf("expected HN link")
|
|
|
|
}
|
|
|
|
res, err := http.Get(URL)
|
|
|
|
if err != nil {
|
|
|
|
return Story{}, err
|
|
|
|
}
|
|
|
|
defer res.Body.Close()
|
|
|
|
if res.StatusCode != 200 {
|
|
|
|
return Story{}, fmt.Errorf("bad response code: %d", res.StatusCode)
|
|
|
|
}
|
|
|
|
|
|
|
|
// Load the HTML document
|
|
|
|
doc, err := goquery.NewDocumentFromReader(res.Body)
|
|
|
|
if err != nil {
|
|
|
|
return Story{}, err
|
|
|
|
}
|
|
|
|
|
|
|
|
// Find the review items
|
|
|
|
title := doc.Find("title").Text()
|
|
|
|
title = strings.ReplaceAll(title, " | Hacker News", "")
|
|
|
|
return Story{
|
|
|
|
Title: title,
|
|
|
|
URL: URL,
|
|
|
|
}, nil
|
2019-07-15 16:46:24 +00:00
|
|
|
}
|
2019-07-15 18:57:23 +00:00
|
|
|
|
2019-07-15 20:55:35 +00:00
|
|
|
func (w *Webshit) updateScores(results []WeeklyResult) error {
|
2019-07-15 18:57:23 +00:00
|
|
|
tx := w.db.MustBegin()
|
|
|
|
for _, res := range results {
|
2019-07-15 20:55:35 +00:00
|
|
|
if _, err := tx.Exec(`update webshit_balances set score=? where user=?`,
|
|
|
|
res.Score, res.User); err != nil {
|
2019-07-15 18:57:23 +00:00
|
|
|
tx.Rollback()
|
|
|
|
return err
|
|
|
|
}
|
|
|
|
}
|
|
|
|
err := tx.Commit()
|
|
|
|
return err
|
|
|
|
}
|
2019-07-15 20:55:35 +00:00
|
|
|
|
|
|
|
func wrMapToSlice(wr map[string]WeeklyResult) []WeeklyResult {
|
|
|
|
var out = []WeeklyResult{}
|
|
|
|
for _, r := range wr {
|
|
|
|
out = append(out, r)
|
|
|
|
}
|
|
|
|
return out
|
|
|
|
}
|