204 lines
5.3 KiB
Go
204 lines
5.3 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"runtime"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
"github.com/denisbrodbeck/striphtmltags"
|
|
log "github.com/go-pkgz/lgr"
|
|
flags "github.com/jessevdk/go-flags"
|
|
|
|
"git.lattuga.net/boyska/rss2twitter/app/rss"
|
|
)
|
|
|
|
type opts struct {
|
|
Refresh time.Duration `short:"r" long:"refresh" env:"REFRESH" default:"30s" description:"refresh interval"`
|
|
TimeOut time.Duration `short:"t" long:"timeout" env:"TIMEOUT" default:"5s" description:"rss feed timeout"`
|
|
Feed string `short:"f" long:"feed" env:"FEED" required:"true" description:"rss feed url"`
|
|
|
|
Dry bool `long:"dry" env:"DRY" description:"dry mode"`
|
|
Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"`
|
|
Command struct {
|
|
Name string
|
|
Args []string
|
|
} `positional-args:"yes"`
|
|
}
|
|
|
|
var revision = "unknown"
|
|
|
|
type notifier interface {
|
|
Go(ctx context.Context) <-chan rss.Event
|
|
}
|
|
|
|
type Publisher interface {
|
|
Publish(event rss.Event, formatter func(rss.Event) string) error
|
|
}
|
|
|
|
// Stdout implements publisher.Interface and sends to stdout
|
|
type Stdout struct{}
|
|
|
|
// Publish to logger
|
|
func (s Stdout) Publish(event rss.Event, formatter func(rss.Event) string) error {
|
|
log.Printf("[INFO] event - %s", formatter(event))
|
|
return nil
|
|
}
|
|
|
|
type CommandPublisher struct {
|
|
Command []string
|
|
}
|
|
|
|
func (p CommandPublisher) Publish(event rss.Event, formatter func(rss.Event) string) error {
|
|
log.Printf("[INFO] lancio - %s ", strings.Join(p.Command, " "))
|
|
cmd := exec.Command(p.Command[0], p.Command[1:]...)
|
|
enclosuresUrls := []string{}
|
|
for _, enclosure := range event.Item.Enclosures {
|
|
enclosuresUrls = append(enclosuresUrls, url.QueryEscape(enclosure.URL))
|
|
}
|
|
cmd.Env = []string{"RSS=1",
|
|
fmt.Sprintf("RSS_TITLE=%s", event.Title),
|
|
fmt.Sprintf("RSS_GUID=%s", event.GUID),
|
|
fmt.Sprintf("RSS_LINK=%s", event.Link),
|
|
fmt.Sprintf("RSS_PUBDATE=%s", event.Item.Published),
|
|
fmt.Sprintf("RSS_CATEGORIES=%s", strings.Join(event.Item.Categories, ";")),
|
|
fmt.Sprintf("RSS_DESCRIPTION=%s", event.Item.Description),
|
|
fmt.Sprintf("RSS_ENCLOSURES=%s", strings.Join(enclosuresUrls, ";")),
|
|
// metadata that apply to the whole feed
|
|
fmt.Sprintf("RSS_CHAN_TITLE=%s", event.Feed.Title),
|
|
fmt.Sprintf("RSS_CHAN_LINK=%s", event.Feed.Link),
|
|
fmt.Sprintf("RSS_CHAN_LANGUAGE=%s", event.Feed.Language),
|
|
}
|
|
if event.Item.Author != nil {
|
|
cmd.Env = append(cmd.Env,
|
|
fmt.Sprintf("RSS_AUTHOR=%s", event.Item.Author.Name),
|
|
fmt.Sprintf("RSS_AUTHOR_EMAIL=%s", event.Item.Author.Email),
|
|
)
|
|
}
|
|
if event.Feed.Author != nil {
|
|
cmd.Env = append(cmd.Env,
|
|
fmt.Sprintf("RSS_CHAN_AUTHOR=%s", event.Feed.Author.Name))
|
|
}
|
|
if event.Item.Image != nil {
|
|
cmd.Env = append(cmd.Env, fmt.Sprintf("RSS_IMAGE=%s", event.Item.Image.URL))
|
|
}
|
|
textbuf := bytes.NewBuffer([]byte(event.Item.Content))
|
|
cmd.Stdin = textbuf
|
|
cmd.Stdout = os.Stderr
|
|
cmdErr := cmd.Run()
|
|
|
|
// TODO: move to goroutine?
|
|
|
|
return cmdErr
|
|
}
|
|
|
|
func main() {
|
|
fmt.Printf("rss2cmd - %s\n", revision)
|
|
o := opts{}
|
|
var err error
|
|
if _, err = flags.Parse(&o); err != nil {
|
|
os.Exit(2)
|
|
}
|
|
fmt.Println(o)
|
|
fmt.Println(o.Command)
|
|
if o.Command.Name != "" && o.Dry {
|
|
fmt.Fprintln(os.Stderr, "Error: if you specify --dry you can't specify a command")
|
|
os.Exit(2)
|
|
}
|
|
if o.Command.Name == "" && !o.Dry {
|
|
fmt.Fprintln(os.Stderr, "Error: you must specify a command! (or use --dry)")
|
|
os.Exit(2)
|
|
}
|
|
|
|
if o.Dbg {
|
|
log.Setup(log.Debug)
|
|
}
|
|
|
|
notif, pub, err := setup(o)
|
|
if err != nil {
|
|
log.Printf("[PANIC] failed to setup, %v", err)
|
|
}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
go func() { // catch SIGTERM signal and invoke graceful termination
|
|
stop := make(chan os.Signal, 1)
|
|
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
|
|
<-stop
|
|
log.Printf("[WARN] interrupt signal")
|
|
cancel()
|
|
}()
|
|
|
|
do(ctx, notif, pub)
|
|
log.Print("[INFO] terminated")
|
|
}
|
|
|
|
func setup(o opts) (n notifier, pub Publisher, err error) {
|
|
n = &rss.Notify{Feed: o.Feed, Duration: o.Refresh, Timeout: o.TimeOut}
|
|
if o.Dry {
|
|
pub = Stdout{}
|
|
} else {
|
|
cmd := append([]string{o.Command.Name}, o.Command.Args...)
|
|
pub = CommandPublisher{Command: cmd}
|
|
fmt.Println(cmd)
|
|
}
|
|
return n, pub, nil
|
|
}
|
|
|
|
// do runs event loop getting rss events and publishing them
|
|
func do(ctx context.Context, notif notifier, pub Publisher) {
|
|
ch := notif.Go(ctx)
|
|
for event := range ch {
|
|
err := pub.Publish(event, func(r rss.Event) string {
|
|
return ""
|
|
})
|
|
if err != nil {
|
|
log.Printf("[WARN] failed to publish, %s", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
// format cleans text (removes html tags) and shrinks result
|
|
func format(inp string, max int) string {
|
|
res := striphtmltags.StripTags(inp)
|
|
if len([]rune(res)) > max {
|
|
snippet := []rune(res)[:max]
|
|
// go back in snippet and found first space
|
|
for i := len(snippet) - 1; i >= 0; i-- {
|
|
if snippet[i] == ' ' {
|
|
snippet = snippet[:i]
|
|
break
|
|
}
|
|
}
|
|
res = string(snippet) + " ..."
|
|
}
|
|
return res
|
|
}
|
|
|
|
// getDump reads runtime stack and returns as a string
|
|
func getDump() string {
|
|
maxSize := 5 * 1024 * 1024
|
|
stacktrace := make([]byte, maxSize)
|
|
length := runtime.Stack(stacktrace, true)
|
|
if length > maxSize {
|
|
length = maxSize
|
|
}
|
|
return string(stacktrace[:length])
|
|
}
|
|
|
|
func init() {
|
|
// catch SIGQUIT and print stack traces
|
|
sigChan := make(chan os.Signal)
|
|
go func() {
|
|
for range sigChan {
|
|
log.Printf("[INFO] SIGQUIT detected, dump:\n%s", getDump())
|
|
}
|
|
}()
|
|
signal.Notify(sigChan, syscall.SIGQUIT)
|
|
}
|