rss2hook.go 5.3 KB

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