main.go 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196
  1. package main
  2. import (
  3. "bytes"
  4. "context"
  5. "fmt"
  6. "net/url"
  7. "os"
  8. "os/exec"
  9. "os/signal"
  10. "runtime"
  11. "strings"
  12. "syscall"
  13. "time"
  14. "github.com/denisbrodbeck/striphtmltags"
  15. log "github.com/go-pkgz/lgr"
  16. flags "github.com/jessevdk/go-flags"
  17. "git.lattuga.net/boyska/rss2twitter/app/publisher"
  18. "git.lattuga.net/boyska/rss2twitter/app/rss"
  19. )
  20. type opts struct {
  21. Refresh time.Duration `short:"r" long:"refresh" env:"REFRESH" default:"30s" description:"refresh interval"`
  22. TimeOut time.Duration `short:"t" long:"timeout" env:"TIMEOUT" default:"5s" description:"rss feed timeout"`
  23. Feed string `short:"f" long:"feed" env:"FEED" required:"true" description:"rss feed url"`
  24. IncludeFirst bool `long:"include-first" description:"start from the last current item, not with the next one"`
  25. Dry bool `long:"dry" env:"DRY" description:"dry mode"`
  26. Dbg bool `long:"dbg" env:"DEBUG" description:"debug mode"`
  27. Command struct {
  28. Name string
  29. Args []string
  30. } `positional-args:"yes"`
  31. }
  32. var revision = "unknown"
  33. type notifier interface {
  34. Go(ctx context.Context) <-chan rss.Event
  35. }
  36. // CommandPublisher runs a command for every event
  37. type CommandPublisher struct {
  38. Command []string
  39. }
  40. // Publish run the command for event. Most rss fields are translated as env vars
  41. func (p CommandPublisher) Publish(event rss.Event, formatter func(rss.Event) string) error {
  42. log.Printf("[INFO] lancio - %s ", strings.Join(p.Command, " "))
  43. cmd := exec.Command(p.Command[0], p.Command[1:]...)
  44. enclosuresUrls := []string{}
  45. for _, enclosure := range event.Item.Enclosures {
  46. enclosuresUrls = append(enclosuresUrls, url.QueryEscape(enclosure.URL))
  47. }
  48. cmd.Env = []string{"RSS=1",
  49. fmt.Sprintf("RSS_TITLE=%s", event.Title),
  50. fmt.Sprintf("RSS_GUID=%s", event.GUID),
  51. fmt.Sprintf("RSS_LINK=%s", event.Link),
  52. fmt.Sprintf("RSS_PUBDATE=%s", event.Item.Published),
  53. fmt.Sprintf("RSS_CATEGORIES=%s", strings.Join(event.Item.Categories, ";")),
  54. fmt.Sprintf("RSS_DESCRIPTION=%s", event.Item.Description),
  55. fmt.Sprintf("RSS_ENCLOSURES=%s", strings.Join(enclosuresUrls, ";")),
  56. // metadata that apply to the whole feed
  57. fmt.Sprintf("RSS_CHAN_TITLE=%s", event.Feed.Title),
  58. fmt.Sprintf("RSS_CHAN_LINK=%s", event.Feed.Link),
  59. fmt.Sprintf("RSS_CHAN_LANGUAGE=%s", event.Feed.Language),
  60. }
  61. if event.Item.Author != nil {
  62. cmd.Env = append(cmd.Env,
  63. fmt.Sprintf("RSS_AUTHOR=%s", event.Item.Author.Name),
  64. fmt.Sprintf("RSS_AUTHOR_EMAIL=%s", event.Item.Author.Email),
  65. )
  66. }
  67. if event.Feed.Author != nil {
  68. cmd.Env = append(cmd.Env,
  69. fmt.Sprintf("RSS_CHAN_AUTHOR=%s", event.Feed.Author.Name))
  70. }
  71. if event.Item.Image != nil {
  72. cmd.Env = append(cmd.Env, fmt.Sprintf("RSS_IMAGE=%s", event.Item.Image.URL))
  73. }
  74. textbuf := bytes.NewBuffer([]byte(event.Item.Content))
  75. cmd.Stdin = textbuf
  76. cmd.Stdout = os.Stdout
  77. cmd.Stderr = os.Stderr
  78. cmdErr := cmd.Run()
  79. // TODO: move to goroutine?
  80. return cmdErr
  81. }
  82. func main() {
  83. fmt.Printf("rss2cmd - %s\n", revision)
  84. o := opts{}
  85. var err error
  86. if _, err = flags.Parse(&o); err != nil {
  87. os.Exit(2)
  88. }
  89. fmt.Println(o)
  90. fmt.Println(o.Command)
  91. if o.Command.Name != "" && o.Dry {
  92. fmt.Fprintln(os.Stderr, "Error: if you specify --dry you can't specify a command")
  93. os.Exit(2)
  94. }
  95. if o.Command.Name == "" && !o.Dry {
  96. fmt.Fprintln(os.Stderr, "Error: you must specify a command! (or use --dry)")
  97. os.Exit(2)
  98. }
  99. if o.Dbg {
  100. log.Setup(log.Debug, log.Out(os.Stderr))
  101. }
  102. notif, pub, err := setup(o)
  103. if err != nil {
  104. log.Printf("[PANIC] failed to setup, %v", err)
  105. }
  106. ctx, cancel := context.WithCancel(context.Background())
  107. go func() { // catch SIGTERM signal and invoke graceful termination
  108. stop := make(chan os.Signal, 1)
  109. signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
  110. <-stop
  111. log.Printf("[WARN] interrupt signal")
  112. cancel()
  113. }()
  114. do(ctx, notif, pub)
  115. log.Print("[INFO] terminated")
  116. }
  117. func setup(o opts) (n notifier, pub publisher.Interface, err error) {
  118. n = &rss.Notify{Feed: o.Feed, Duration: o.Refresh, Timeout: o.TimeOut, IncludeFirst: o.IncludeFirst}
  119. if o.Dry {
  120. pub = publisher.Stdout{}
  121. } else {
  122. cmd := append([]string{o.Command.Name}, o.Command.Args...)
  123. pub = CommandPublisher{Command: cmd}
  124. fmt.Println(cmd)
  125. }
  126. return n, pub, nil
  127. }
  128. // do runs event loop getting rss events and publishing them
  129. func do(ctx context.Context, notif notifier, pub publisher.Interface) {
  130. ch := notif.Go(ctx)
  131. for event := range ch {
  132. err := pub.Publish(event, func(r rss.Event) string {
  133. return event.Title
  134. })
  135. if err != nil {
  136. log.Printf("[WARN] failed to publish, %s", err)
  137. }
  138. }
  139. }
  140. // format cleans text (removes html tags) and shrinks result
  141. func format(inp string, max int) string {
  142. res := striphtmltags.StripTags(inp)
  143. if len([]rune(res)) > max {
  144. snippet := []rune(res)[:max]
  145. // go back in snippet and found first space
  146. for i := len(snippet) - 1; i >= 0; i-- {
  147. if snippet[i] == ' ' {
  148. snippet = snippet[:i]
  149. break
  150. }
  151. }
  152. res = string(snippet) + " ..."
  153. }
  154. return res
  155. }
  156. // getDump reads runtime stack and returns as a string
  157. func getDump() string {
  158. maxSize := 5 * 1024 * 1024
  159. stacktrace := make([]byte, maxSize)
  160. length := runtime.Stack(stacktrace, true)
  161. if length > maxSize {
  162. length = maxSize
  163. }
  164. return string(stacktrace[:length])
  165. }
  166. func init() {
  167. // catch SIGQUIT and print stack traces
  168. sigChan := make(chan os.Signal)
  169. go func() {
  170. for range sigChan {
  171. log.Printf("[INFO] SIGQUIT detected, dump:\n%s", getDump())
  172. }
  173. }()
  174. signal.Notify(sigChan, syscall.SIGQUIT)
  175. }