rss2hook.go 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217
  1. // rss2hook is a simple utility which will make HTTP POST
  2. // requests to remote web-hooks when new items appear in an RSS feed.
  3. //
  4. // Steve
  5. // poesty
  6. //
  7. package main
  8. import (
  9. "bytes"
  10. "encoding/json"
  11. "flag"
  12. "io"
  13. "log"
  14. "net/http"
  15. "os"
  16. "os/signal"
  17. "reflect"
  18. "syscall"
  19. "time"
  20. "github.com/SlyMarbo/rss"
  21. "github.com/robfig/cron"
  22. )
  23. // RSSEntry describes a single RSS feed and the corresponding hook
  24. type RSSEntry struct {
  25. // The URL of the RSS/Atom feed
  26. Url string `json:"url,omitempty"`
  27. // The retry count
  28. Retry int `json:"retry,omitempty"`
  29. // The hook end-point
  30. Hook string `json:"hook,omitempty"`
  31. }
  32. // Loaded contains the loaded feeds + hooks, as read from the specified
  33. // configuration file
  34. var Loaded []RSSEntry
  35. // Timeout is the (global) timeout we use when loading remote RSS
  36. // feeds.
  37. var Timeout time.Duration
  38. // loadConfig loads the named configuration file and populates our
  39. // `Loaded` list of RSS-feeds & Webhook addresses
  40. func loadConfig(filename string) {
  41. file, err := os.Open(filename)
  42. if err != nil {
  43. log.Fatalf("loadConfig: Error opening %s - %s\n", filename, err.Error())
  44. }
  45. defer file.Close()
  46. err = json.NewDecoder(file).Decode(&Loaded)
  47. if err != nil {
  48. log.Fatalf("loadConfig: Error decoding %s - %s\n", filename, err.Error())
  49. }
  50. log.Printf("Loaded %d feeds\n", len(Loaded))
  51. }
  52. // checkFeeds is our work-horse.
  53. //
  54. // For each available feed it looks for new entries, and when founds
  55. // triggers `notify` upon the resulting entry
  56. func checkFeeds(feeds []rss.Feed) {
  57. //
  58. // For each thing we're monitoring
  59. //
  60. for i, feed := range feeds {
  61. // skip empty feed, probably because:
  62. // 1. feed initialization failed
  63. // 2. retry count exceeded
  64. if reflect.ValueOf(feed).IsZero() {
  65. continue
  66. }
  67. // Fetch the feed-contents
  68. err := feed.Update()
  69. if err != nil {
  70. log.Printf("checkFeeds: Error fetching %s - %s\n",
  71. Loaded[i].Url, err.Error())
  72. if Loaded[i].Retry--; Loaded[i].Retry == 0 {
  73. log.Printf("checkFeeds: Temporally diable feed - %s\n", Loaded[i].Url)
  74. feeds[i] = rss.Feed{}
  75. }
  76. // log.Printf("checkFeeds: refresh time - %s\n", feed.Refresh.Format(time.RFC3339))
  77. continue
  78. }
  79. if feed.Unread != 0 {
  80. notify(i, feed.Items[0]) // tbh idk
  81. feed.Unread = 0
  82. feed.Items = nil // gc
  83. }
  84. }
  85. }
  86. // notify actually submits the specified item to the remote webhook.
  87. // The RSS-item is submitted as a JSON-object.
  88. func notify(i int, item *rss.Item) {
  89. // We'll post the item as a JSON object.
  90. // So first of all encode it.
  91. jsonValue, err := json.Marshal(item)
  92. if err != nil {
  93. log.Fatalf("notify: Failed to encode JSON:%s\n", err.Error())
  94. }
  95. //
  96. // Post to the specified hook URL.
  97. //
  98. res, err := http.Post(Loaded[i].Hook,
  99. "application/json",
  100. bytes.NewBuffer(jsonValue))
  101. if err != nil {
  102. log.Printf("notify: Failed to POST to %s - %s\n",
  103. Loaded[i].Hook, err.Error()) // TODO: retry?
  104. return
  105. }
  106. //
  107. // OK now we've submitted the post.
  108. //
  109. // We should retrieve the status-code + body, if the status-code
  110. // is "odd" then we'll show them.
  111. //
  112. defer res.Body.Close()
  113. _, err = io.ReadAll(res.Body)
  114. if err != nil {
  115. log.Printf("notify: Failed to read response from %s - %s\n",
  116. Loaded[i].Hook, err.Error())
  117. return
  118. }
  119. status := res.StatusCode
  120. if status >= 400 {
  121. log.Printf("notify: Warning - Status code %d from %s\n", status, Loaded[i].Hook)
  122. }
  123. }
  124. // main is our entry-point
  125. func main() {
  126. // Parse the command-line flags
  127. config := flag.String("config", "config.json", "The path to the configuration-file to read")
  128. timeout := flag.Duration("timeout", 5*time.Second, "The timeout used for fetching the remote feeds")
  129. schedule := flag.String("schedule", "@every 5m", "The cron schedule for fetching the remote feeds")
  130. startup := flag.Bool("startup", false, "Run on startup (first check)")
  131. flag.Parse()
  132. // Setup the default timeout and TTL
  133. // Remember that we respect spec, like `ttl` field in RSS 2.0
  134. rss.DefaultRefreshInterval = 10 * time.Second
  135. rss.DefaultFetchFunc = func(url string) (*http.Response, error) {
  136. client := &http.Client{Timeout: *timeout}
  137. return client.Get(url)
  138. }
  139. //
  140. // Load the configuration file
  141. //
  142. loadConfig(*config)
  143. //
  144. // Show the things we're monitoring
  145. //
  146. // for _, ent := range Loaded {
  147. // log.Printf("Monitoring feed %s\nPosting to %s\n\n",
  148. // ent.Feed, ent.Hook)
  149. // }
  150. //
  151. // Make the initial scan of feeds immediately to avoid waiting too
  152. // long for the first time.
  153. //
  154. feeds := make([]rss.Feed, len(Loaded))
  155. for i, ent := range Loaded {
  156. go func(i int, ent RSSEntry) {
  157. feed, err := rss.Fetch(ent.Url)
  158. if err != nil {
  159. log.Printf("main: Error fetching %s - %s\n",
  160. ent.Url, err.Error())
  161. return
  162. }
  163. // TODO: URL validiy checks
  164. if !*startup {
  165. feed.Unread = 0
  166. }
  167. feeds[i] = *feed
  168. }(i, ent)
  169. }
  170. //
  171. // Now repeat that every five minutes or in custom schedule.
  172. //
  173. c := cron.New()
  174. c.AddFunc(*schedule, func() { checkFeeds(feeds) })
  175. c.Start()
  176. //
  177. // Now we can loop waiting to be terminated via ctrl-c, etc.
  178. //
  179. sigs := make(chan os.Signal, 1)
  180. done := make(chan bool, 1)
  181. signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
  182. go func() {
  183. <-sigs
  184. done <- true
  185. }()
  186. <-done
  187. }