123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196 |
- 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/publisher"
- "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"`
- IncludeFirst bool `long:"include-first" description:"start from the last current item, not with the next one"`
- 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
- }
- // CommandPublisher runs a command for every event
- type CommandPublisher struct {
- Command []string
- }
- // Publish run the command for event. Most rss fields are translated as env vars
- 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.Stdout
- cmd.Stderr = 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, log.Out(os.Stderr))
- }
- 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.Interface, err error) {
- n = &rss.Notify{Feed: o.Feed, Duration: o.Refresh, Timeout: o.TimeOut, IncludeFirst: o.IncludeFirst}
- if o.Dry {
- pub = publisher.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.Interface) {
- ch := notif.Go(ctx)
- for event := range ch {
- err := pub.Publish(event, func(r rss.Event) string {
- return event.Title
- })
- 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)
- }
|