rss2hook.go 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  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. //
  6. package main
  7. import (
  8. "bufio"
  9. "bytes"
  10. "crypto/sha1"
  11. "encoding/hex"
  12. "encoding/json"
  13. "flag"
  14. "fmt"
  15. "io/ioutil"
  16. "net/http"
  17. "os"
  18. "os/signal"
  19. "regexp"
  20. "strings"
  21. "syscall"
  22. "time"
  23. "github.com/mmcdole/gofeed"
  24. "github.com/robfig/cron"
  25. )
  26. // RSSEntry describes a single RSS feed and the corresponding hook
  27. // to POST to.
  28. type RSSEntry struct {
  29. // The URL of the RSS/Atom feed
  30. feed string
  31. // The end-point to make the webhook request to.
  32. hook string
  33. }
  34. // Loaded contains the loaded feeds + hooks, as read from the specified
  35. // configuration file
  36. var Loaded []RSSEntry
  37. // loadConfig loads the named configuration file and populates our
  38. // `Loaded` list of RSS-feeds & Webhook addresses
  39. func loadConfig(filename string) {
  40. file, err := os.Open(filename)
  41. if err != nil {
  42. fmt.Printf("Error opening %s - %s\n", filename, err.Error())
  43. return
  44. }
  45. defer file.Close()
  46. //
  47. // Process it line by line.
  48. //
  49. scanner := bufio.NewScanner(file)
  50. for scanner.Scan() {
  51. // Get the next line, and strip leading/trailing space
  52. tmp := scanner.Text()
  53. tmp = strings.TrimSpace(tmp)
  54. //
  55. // Skip lines that begin with a comment.
  56. //
  57. if (tmp != "") && (!strings.HasPrefix(tmp, "#")) {
  58. //
  59. // Otherwise find the feed + post-point
  60. //
  61. parser := regexp.MustCompile("^(.*)=([^=]+)")
  62. match := parser.FindStringSubmatch(tmp)
  63. //
  64. // OK we found a suitable entry.
  65. //
  66. if len(match) == 3 {
  67. feed := strings.TrimSpace(match[1])
  68. hook := strings.TrimSpace(match[2])
  69. // Append the new entry to our list
  70. entry := RSSEntry{feed: feed, hook: hook}
  71. Loaded = append(Loaded, entry)
  72. }
  73. }
  74. }
  75. }
  76. // fetchFeed fetches the contents of the specified URL.
  77. func fetchFeed(url string) (string, error) {
  78. // Ensure we setup a timeout for our fetch
  79. client := &http.Client{Timeout: time.Duration(5 * time.Second)}
  80. // We'll only make a GET request
  81. req, err := http.NewRequest("GET", url, nil)
  82. if err != nil {
  83. return "", err
  84. }
  85. // We ensure we identify ourself.
  86. req.Header.Set("User-Agent", "rss2email (https://github.com/skx/rss2email)")
  87. // Make the request
  88. resp, err := client.Do(req)
  89. if err != nil {
  90. return "", err
  91. }
  92. defer resp.Body.Close()
  93. // Read the body returned
  94. output, err := ioutil.ReadAll(resp.Body)
  95. if err != nil {
  96. return "", err
  97. }
  98. return string(output), nil
  99. }
  100. // isNew returns TRUE if this feed-item hasn't been notified about
  101. // previously.
  102. func isNew(parent string, item *gofeed.Item) bool {
  103. hasher := sha1.New()
  104. hasher.Write([]byte(parent))
  105. hasher.Write([]byte(item.GUID))
  106. hashBytes := hasher.Sum(nil)
  107. // Hexadecimal conversion
  108. hexSha1 := hex.EncodeToString(hashBytes)
  109. if _, err := os.Stat(os.Getenv("HOME") + "/.rss2hook/seen/" + hexSha1); os.IsNotExist(err) {
  110. return true
  111. }
  112. return false
  113. }
  114. // recordSeen ensures that we won't re-announce a given feed-item.
  115. func recordSeen(parent string, item *gofeed.Item) {
  116. hasher := sha1.New()
  117. hasher.Write([]byte(parent))
  118. hasher.Write([]byte(item.GUID))
  119. hashBytes := hasher.Sum(nil)
  120. // Hexadecimal conversion
  121. hexSha1 := hex.EncodeToString(hashBytes)
  122. dir := os.Getenv("HOME") + "/.rss2hook/seen"
  123. os.MkdirAll(dir, os.ModePerm)
  124. _ = ioutil.WriteFile(dir+"/"+hexSha1, []byte(item.Link), 0644)
  125. }
  126. // checkFeeds is our work-horse.
  127. //
  128. // For each available feed it looks for new entries, and when founds
  129. // triggers `notify` upon the resulting entry
  130. func checkFeeds() {
  131. //
  132. // For each thing we're monitoring
  133. //
  134. for _, monitor := range Loaded {
  135. // Fetch the feed-contents
  136. content, err := fetchFeed(monitor.feed)
  137. if err != nil {
  138. fmt.Printf("Error fetching %s - %s\n",
  139. monitor.feed, err.Error())
  140. continue
  141. }
  142. // Now parse the feed contents into a set of items
  143. fp := gofeed.NewParser()
  144. feed, err := fp.ParseString(content)
  145. if err != nil {
  146. fmt.Printf("Error parsing %s contents: %s\n", monitor.feed, err.Error())
  147. continue
  148. }
  149. // For each entry in the feed
  150. for _, i := range feed.Items {
  151. // If we've not already notified about this one.
  152. if isNew(monitor.feed, i) {
  153. // Trigger the notification
  154. err := notify(monitor.hook, i)
  155. // and if that notification succeeded
  156. // then record this item as having been
  157. // processed successfully.
  158. if err == nil {
  159. recordSeen(monitor.feed, i)
  160. }
  161. }
  162. }
  163. }
  164. }
  165. // notify actually submits the specified item to the remote webhook.
  166. //
  167. // The RSS-item is submitted as a JSON-object.
  168. func notify(hook string, item *gofeed.Item) error {
  169. // We'll post the item as a JSON object.
  170. // So first of all encode it.
  171. jsonValue, err := json.Marshal(item)
  172. if err != nil {
  173. fmt.Printf("notify: Failed to encode JSON:%s\n", err.Error())
  174. return err
  175. }
  176. //
  177. // Post to the specified hook URL.
  178. //
  179. res, err := http.Post(hook,
  180. "application/json",
  181. bytes.NewBuffer(jsonValue))
  182. if err != nil {
  183. fmt.Printf("notify: Failed to POST to %s - %s\n",
  184. hook, err.Error())
  185. return err
  186. }
  187. //
  188. // OK now we've submitted the post.
  189. //
  190. // We should retrieve the status-code + body, if the status-code
  191. // is "odd" then we'll show them.
  192. //
  193. defer res.Body.Close()
  194. _, err = ioutil.ReadAll(res.Body)
  195. if err != nil {
  196. return err
  197. }
  198. status := res.StatusCode
  199. if status != 200 {
  200. fmt.Printf("notify: Warning - Status code was not 200: %d\n", status)
  201. }
  202. return nil
  203. }
  204. // main is our entry-point
  205. func main() {
  206. // Parse the command-line flags
  207. config := flag.String("config", "", "The path to the configuration-file to read")
  208. flag.Parse()
  209. if *config == "" {
  210. fmt.Printf("Please specify a configuration-file to read\n")
  211. return
  212. }
  213. //
  214. // Load the configuration file
  215. //
  216. loadConfig(*config)
  217. //
  218. // Show the things we're monitoring
  219. //
  220. for _, ent := range Loaded {
  221. fmt.Printf("Monitoring feed %s\nPosting to %s\n\n",
  222. ent.feed, ent.hook)
  223. }
  224. //
  225. // Make the initial scan of feeds immediately to avoid waiting too
  226. // long for the first time.
  227. //
  228. checkFeeds()
  229. //
  230. // Now repeat that every five minutes.
  231. //
  232. c := cron.New()
  233. c.AddFunc("@every 5m", func() { checkFeeds() })
  234. c.Start()
  235. //
  236. // Now we can loop waiting to be terminated via ctrl-c, etc.
  237. //
  238. sigs := make(chan os.Signal, 1)
  239. done := make(chan bool, 1)
  240. signal.Notify(sigs, syscall.SIGINT, syscall.SIGTERM)
  241. go func() {
  242. _ = <-sigs
  243. done <- true
  244. }()
  245. <-done
  246. }