├── .github ├── dependabot.yml └── workflows │ ├── build.yml │ ├── docker.yml │ ├── golangci-lint.yml │ └── update.yml ├── .gitignore ├── .golangci.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── Readme.md ├── cmd ├── db_printer │ └── main.go └── rss_fetcher │ └── main.go ├── config.json.sample ├── go.mod ├── go.sum ├── install_service.sh ├── internal ├── config │ ├── config.go │ └── config_test.go ├── database │ ├── database.go │ └── database_test.go ├── feed │ ├── feed.go │ └── feed_test.go ├── helper │ └── helper.go ├── mail │ ├── mail.go │ └── mail_test.go └── pb │ └── rss.pb.go ├── proto └── rss.proto ├── rss_fetcher.service ├── rss_fetcher.timer └── testdata ├── invalid.json ├── invalid.testdb ├── invalid_feed.xml ├── rss.testdb ├── test.json └── valid_feed.xml /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://help.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" 9 | directory: "/" 10 | target-branch: "dev" 11 | schedule: 12 | interval: "weekly" 13 | 14 | - package-ecosystem: "github-actions" 15 | directory: "/" 16 | schedule: 17 | # Check for updates to GitHub Actions every weekday 18 | interval: "daily" 19 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Checkout sources 10 | uses: actions/checkout@v4 11 | 12 | - name: Set up Go 13 | uses: actions/setup-go@v5 14 | with: 15 | go-version: "stable" 16 | 17 | - name: go version 18 | run: go version 19 | 20 | - name: Build and Test 21 | run: | 22 | make build test 23 | -------------------------------------------------------------------------------- /.github/workflows/docker.yml: -------------------------------------------------------------------------------- 1 | name: Build Docker Images 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | workflow_dispatch: 8 | schedule: 9 | - cron: "0 0 * * *" 10 | 11 | jobs: 12 | Dockerhub: 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: read 16 | packages: write 17 | 18 | steps: 19 | - name: checkout sources 20 | uses: actions/checkout@v4 21 | 22 | - name: Set up QEMU 23 | uses: docker/setup-qemu-action@v3 24 | 25 | - name: Set up Docker Buildx 26 | uses: docker/setup-buildx-action@v3 27 | 28 | - name: Login to GitHub Container Registry 29 | uses: docker/login-action@v3 30 | with: 31 | registry: ghcr.io 32 | username: ${{ github.repository_owner }} 33 | password: ${{ secrets.GITHUB_TOKEN }} 34 | 35 | - name: Build and push 36 | uses: docker/build-push-action@v6 37 | with: 38 | push: true 39 | platforms: linux/amd64,linux/arm/v7,linux/arm64/v8,linux/386,linux/ppc64le 40 | tags: | 41 | ghcr.io/firefart/rss_fetcher:latest 42 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: [push, workflow_dispatch] 3 | jobs: 4 | golangci: 5 | name: lint 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v4 9 | - uses: actions/setup-go@v5 10 | with: 11 | go-version: "stable" 12 | - name: golangci-lint 13 | uses: golangci/golangci-lint-action@v8 14 | with: 15 | version: latest 16 | -------------------------------------------------------------------------------- /.github/workflows/update.yml: -------------------------------------------------------------------------------- 1 | name: Update 2 | 3 | on: 4 | workflow_dispatch: 5 | schedule: 6 | - cron: "0 12 * * *" 7 | 8 | jobs: 9 | update: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: checkout 13 | uses: actions/checkout@v4 14 | with: 15 | token: ${{ secrets.PERSONAL_ACCESS_TOKEN_UPDATE }} 16 | 17 | - name: Set up Go 18 | uses: actions/setup-go@v5 19 | with: 20 | go-version: "stable" 21 | 22 | - name: update 23 | run: | 24 | make update 25 | 26 | - name: setup git config 27 | run: | 28 | git config user.name "Github" 29 | git config user.email "<>" 30 | 31 | - name: commit changes 32 | # need to override the default shell so we can check 33 | # for error codes. Otherwise it will always fail if 34 | # one command returns an error code other than 0 35 | shell: bash --noprofile --norc -o pipefail {0} 36 | run: | 37 | git diff-index --quiet HEAD -- 38 | exit_status=$? 39 | if [ $exit_status -eq 0 ]; then 40 | echo "nothing has changed" 41 | else 42 | git add go.mod go.sum internal/pb/rss.pb.go 43 | git commit -m "auto update from github actions" 44 | git push origin main 45 | fi 46 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .git/ 2 | out/ 3 | config.json 4 | rss_fetcher 5 | !rss_fetcher/ 6 | db_printer 7 | !db_printer/ 8 | rss_fetcher.exe 9 | .last_update 10 | *.swp 11 | *.db -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | version: "2" 2 | linters: 3 | enable: 4 | - nonamedreturns 5 | exclusions: 6 | generated: lax 7 | presets: 8 | - comments 9 | - common-false-positives 10 | - legacy 11 | - std-error-handling 12 | paths: 13 | - third_party$ 14 | - builtin$ 15 | - examples$ 16 | formatters: 17 | exclusions: 18 | generated: lax 19 | paths: 20 | - third_party$ 21 | - builtin$ 22 | - examples$ 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:latest AS build-env 2 | WORKDIR /src 3 | ENV CGO_ENABLED=0 4 | COPY go.mod /src/ 5 | RUN go mod download 6 | COPY . . 7 | RUN go build -a -o rss_fetcher -trimpath ./cmd/rss_fetcher 8 | 9 | FROM alpine:latest 10 | 11 | RUN apk add --no-cache ca-certificates \ 12 | && rm -rf /var/cache/* 13 | 14 | RUN mkdir -p /app \ 15 | && adduser -D user \ 16 | && chown -R user:user /app 17 | 18 | USER user 19 | WORKDIR /app 20 | 21 | COPY --from=build-env /src/rss_fetcher . 22 | 23 | ENTRYPOINT [ "/app/rss_fetcher" ] 24 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Christian Mehlmauer 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .DEFAULT_GOAL := build 2 | 3 | .PHONY: build 4 | build: 5 | CGO_ENABLED=0 go build -buildvcs=false -trimpath -o rss_fetcher ./cmd/rss_fetcher 6 | CGO_ENABLED=0 go build -buildvcs=false -trimpath -o db_printer ./cmd/db_printer 7 | 8 | .PHONY: linux 9 | linux: protoc update test 10 | GOOS=linux GOARCH=amd64 go build -buildvcs=false -trimpath . 11 | 12 | .PHONY: test 13 | test: 14 | go test -v -race ./... 15 | 16 | .PHONY: update 17 | update: protoc 18 | go get -u ./... 19 | go mod tidy -v 20 | 21 | .PHONY: lint 22 | lint: 23 | "$$(go env GOPATH)/bin/golangci-lint" run ./... 24 | go mod tidy 25 | 26 | .PHONY: lint-update 27 | lint-update: 28 | curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b $$(go env GOPATH)/bin 29 | $$(go env GOPATH)/bin/golangci-lint --version 30 | 31 | .PHONY: install-protoc 32 | install-protoc: 33 | mkdir -p /tmp/protoc 34 | curl -s -L https://api.github.com/repos/protocolbuffers/protobuf/releases/latest | jq '.assets[] | select(.name | endswith("-linux-x86_64.zip")) | .browser_download_url' | xargs curl -s -L -o /tmp/protoc/protoc.zip 35 | unzip -d /tmp/protoc/ /tmp/protoc/protoc.zip 36 | sudo mv /tmp/protoc/bin/protoc /usr/bin/protoc 37 | sudo rm -rf /usr/local/include/google 38 | sudo mv /tmp/protoc/include/* /usr/local/include 39 | rm -rf /tmp/protoc 40 | 41 | .PHONY: protoc 42 | protoc: install-protoc 43 | go install google.golang.org/protobuf/cmd/protoc-gen-go@latest 44 | protoc -I ./proto -I /usr/local/include/ ./proto/rss.proto --go_out=. 45 | -------------------------------------------------------------------------------- /Readme.md: -------------------------------------------------------------------------------- 1 | # RSS Fetcher 2 | 3 | This little GO program is intended to fetch all configured RSS or ATOM feeds every hour (configurable) and send new entries per E-Mail. 4 | 5 | This project is mainly written because IFTT can not handle crt.sh feeds :/ 6 | 7 | Expected errors during execution are also sent via E-Mail to the E-Mail address configured in `config.json`. 8 | 9 | For sending mails you should setup a local SMTP server like postfix to handle resubmission, signing and so on for you. SMTP authentication is currently not implemented. 10 | 11 | The program keeps the last date of the last entry per feed in it's database to compare it to on the next fetch. 12 | We can't just use the current date because crt.sh is caching it's feeds and they do not appear at the time written in the feed. 13 | 14 | ## Installation on a systemd based system 15 | 16 | - Build binary or download it 17 | 18 | ```bash 19 | make 20 | ``` 21 | 22 | or 23 | 24 | ```bash 25 | go get 26 | go build 27 | ``` 28 | 29 | or 30 | 31 | ```bash 32 | make_linux.bat 33 | make_windows.bat 34 | ``` 35 | 36 | - Add a user to run the binary 37 | 38 | ```bash 39 | adduser --system rss 40 | ``` 41 | 42 | - Copy everything to home dir 43 | 44 | ```bash 45 | cp -R checkout_dir /home/rss/ 46 | ``` 47 | 48 | - Modify run time (if you want to run it at other intervalls) 49 | 50 | ```bash 51 | vim /home/rss/rss_fetcher.timer 52 | ``` 53 | 54 | - Edit the config 55 | 56 | ```bash 57 | cp /home/rss/config.json.sample /home/rss/config.json 58 | vim /home/rss/config.json 59 | ``` 60 | 61 | - Install the service and timer files 62 | 63 | ```bash 64 | ./install_service.sh 65 | ``` 66 | 67 | - Watch the logs 68 | 69 | ```bash 70 | journalctl -u rss_fetcher.service -f 71 | ``` 72 | 73 | ## Config Sample 74 | 75 | ```json 76 | { 77 | "timeout": 10, 78 | "mailserver": "localhost", 79 | "mailport": 25, 80 | "mailfrom": "RSS ", 81 | "mailto": "People ", 82 | "mailonerror": true, 83 | "mailtoerror": "c@c.com", 84 | "database": "rss.db", 85 | "globalignorewords": ["ignore1", "ignore2"], 86 | "feeds": [ 87 | { 88 | "title": "Certificates *.aaa.com", 89 | "url": "https://crt.sh/atom?q=%25.aaa.com", 90 | "ignorewords": ["[Precertificate]", "ignore2"] 91 | }, 92 | { 93 | "title": "Certificates *.bbb.com", 94 | "url": "https://crt.sh/atom?q=%25.bbb.com" 95 | } 96 | ] 97 | } 98 | ``` 99 | -------------------------------------------------------------------------------- /cmd/db_printer/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "log" 7 | "os" 8 | "text/tabwriter" 9 | "time" 10 | 11 | "github.com/FireFart/rss_fetcher/internal/config" 12 | "github.com/FireFart/rss_fetcher/internal/database" 13 | "github.com/FireFart/rss_fetcher/internal/helper" 14 | ) 15 | 16 | var ( 17 | configFile = flag.String("config", "", "Config File to use") 18 | ) 19 | 20 | func main() { 21 | flag.Parse() 22 | 23 | config, err := config.GetConfig(*configFile) 24 | if err != nil { 25 | log.Fatalf("could not parse config file: %v", err) 26 | } 27 | 28 | r, err := database.ReadDatabase(config.Database) 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | 33 | fmt.Printf("Last Run: %s\n", helper.TimeToString(time.Unix(0, r.LastRun))) 34 | w := tabwriter.NewWriter(os.Stdout, 0, 0, 1, ' ', tabwriter.AlignRight|tabwriter.Debug) 35 | for key, element := range r.Feeds { 36 | fmt.Fprintf(w, "%s\t%s\n", key, helper.TimeToString(time.Unix(0, element))) 37 | } 38 | w.Flush() 39 | } 40 | -------------------------------------------------------------------------------- /cmd/rss_fetcher/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "os" 7 | "time" 8 | 9 | "github.com/FireFart/rss_fetcher/internal/config" 10 | "github.com/FireFart/rss_fetcher/internal/database" 11 | "github.com/FireFart/rss_fetcher/internal/feed" 12 | "github.com/FireFart/rss_fetcher/internal/helper" 13 | 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | var ( 18 | debug = flag.Bool("debug", false, "Print debug output") 19 | test = flag.Bool("test", false, "do not send mails, print them instead") 20 | configFile = flag.String("config", "", "Config File to use") 21 | ) 22 | 23 | func main() { 24 | flag.Parse() 25 | 26 | log.SetOutput(os.Stdout) 27 | log.SetLevel(log.InfoLevel) 28 | if *debug { 29 | log.SetLevel(log.DebugLevel) 30 | } 31 | 32 | config, err := config.GetConfig(*configFile) 33 | if err != nil { 34 | log.Fatalf("could not parse config file: %v", err) 35 | } 36 | config.Test = *test 37 | 38 | log.Println("Starting RSS Fetcher") 39 | start := time.Now().UnixNano() 40 | r, err := database.ReadDatabase(config.Database) 41 | if err != nil { 42 | helper.ProcessError(*config, fmt.Errorf("error in database file: %v", err)) 43 | os.Exit(1) 44 | } 45 | 46 | database.CleanupDatabase(r, *config) 47 | 48 | for _, f := range config.Feeds { 49 | log.Printf("processing feed %q (%s)", f.Title, f.URL) 50 | last, ok := r.Feeds[f.URL] 51 | // if it's a new feed only process new entries and ignore old ones 52 | if !ok { 53 | last = start 54 | } 55 | entry, errFeed := feed.ProcessFeed(*config, f, last) 56 | if errFeed != nil { 57 | helper.ProcessError(*config, errFeed) 58 | } else { 59 | r.Feeds[f.URL] = entry 60 | } 61 | } 62 | r.LastRun = start 63 | err = database.SaveDatabase(config.Database, r) 64 | if err != nil { 65 | helper.ProcessError(*config, fmt.Errorf("error on writing database file: %v", err)) 66 | os.Exit(1) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /config.json.sample: -------------------------------------------------------------------------------- 1 | { 2 | "timeout": 10, 3 | "mailserver": "localhost", 4 | "mailport": 25, 5 | "mailfrom": "RSS ", 6 | "mailto": "People ", 7 | "mailonerror": true, 8 | "mailtoerror": "c@c.com", 9 | "database": "rss.db", 10 | "globalignorewords": ["ignore1", "ignore2"], 11 | "feeds": [ 12 | { 13 | "title": "Certificates *.aaa.com", 14 | "url": "https://crt.sh/atom?q=%25.aaa.com", 15 | "ignorewords": ["ignore1", "ignore2"] 16 | }, 17 | { 18 | "title": "Certificates *.bbb.com", 19 | "url": "https://crt.sh/atom?q=%25.bbb.com" 20 | } 21 | ] 22 | } 23 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/FireFart/rss_fetcher 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/mmcdole/gofeed v1.3.0 7 | github.com/sirupsen/logrus v1.9.3 8 | google.golang.org/protobuf v1.36.6 9 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df 10 | ) 11 | 12 | require ( 13 | github.com/PuerkitoBio/goquery v1.10.3 // indirect 14 | github.com/andybalholm/cascadia v1.3.3 // indirect 15 | github.com/json-iterator/go v1.1.12 // indirect 16 | github.com/mmcdole/goxpp v1.1.1 // indirect 17 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 18 | github.com/modern-go/reflect2 v1.0.2 // indirect 19 | golang.org/x/net v0.40.0 // indirect 20 | golang.org/x/sys v0.33.0 // indirect 21 | golang.org/x/text v0.25.0 // indirect 22 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc // indirect 23 | ) 24 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/PuerkitoBio/goquery v1.10.3 h1:pFYcNSqHxBD06Fpj/KsbStFRsgRATgnf3LeXiUkhzPo= 2 | github.com/PuerkitoBio/goquery v1.10.3/go.mod h1:tMUX0zDMHXYlAQk6p35XxQMqMweEKB7iK7iLNd4RH4Y= 3 | github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM= 4 | github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= 5 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 6 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 7 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 8 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 9 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 10 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 11 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 12 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 13 | github.com/mmcdole/gofeed v1.3.0 h1:5yn+HeqlcvjMeAI4gu6T+crm7d0anY85+M+v6fIFNG4= 14 | github.com/mmcdole/gofeed v1.3.0/go.mod h1:9TGv2LcJhdXePDzxiuMnukhV2/zb6VtnZt1mS+SjkLE= 15 | github.com/mmcdole/goxpp v1.1.1 h1:RGIX+D6iQRIunGHrKqnA2+700XMCnNv0bAOOv5MUhx8= 16 | github.com/mmcdole/goxpp v1.1.1/go.mod h1:v+25+lT2ViuQ7mVxcncQ8ch1URund48oH+jhjiwEgS8= 17 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 18 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 19 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 20 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 21 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 22 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 23 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 24 | github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= 25 | github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= 26 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 27 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 28 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 29 | github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= 30 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 31 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 32 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 33 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 34 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 35 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 36 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 37 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 38 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 39 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 40 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 41 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 42 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 43 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 44 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 45 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 46 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 47 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 48 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 49 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 50 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 51 | golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4= 52 | golang.org/x/net v0.40.0 h1:79Xs7wF06Gbdcg4kdCCIQArK11Z1hr5POQ6+fIYHNuY= 53 | golang.org/x/net v0.40.0/go.mod h1:y0hY0exeL2Pku80/zKK7tpntoX23cqL3Oa6njdgRtds= 54 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 55 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 56 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 57 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 58 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 59 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 60 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 61 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 62 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 63 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 64 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 65 | golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 66 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 67 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 68 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 69 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 70 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 71 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 72 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 73 | golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= 74 | golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 75 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 76 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 77 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 78 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 79 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 80 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 81 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 82 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 83 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 84 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 85 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 86 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 87 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 88 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 89 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 90 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 91 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 92 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 93 | golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4= 94 | golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 95 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 96 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 97 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 98 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 99 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 100 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 101 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 102 | google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= 103 | google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= 104 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc h1:2gGKlE2+asNV9m7xrywl36YYNnBG5ZQ0r/BOOxqPpmk= 105 | gopkg.in/alexcesaro/quotedprintable.v3 v3.0.0-20150716171945-2caba252f4dc/go.mod h1:m7x9LTH6d71AHyAX77c9yqWCCa3UKHcVEj9y7hAtKDk= 106 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 107 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df h1:n7WqCuqOuCbNr617RXOY0AWRXxgwEyPp2z+p0+hgMuE= 108 | gopkg.in/gomail.v2 v2.0.0-20160411212932-81ebce5c23df/go.mod h1:LRQQ+SO6ZHR7tOkpBDuZnXENFzX8qRjMDMyPD6BRkCw= 109 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 110 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 111 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 112 | -------------------------------------------------------------------------------- /install_service.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | echo "Copying unit file" 4 | cp /home/rss/rss_fetcher.service /etc/systemd/system/rss_fetcher.service 5 | cp /home/rss/rss_fetcher.timer /etc/systemd/system/rss_fetcher.timer 6 | echo "reloading systemctl" 7 | systemctl daemon-reload 8 | echo "enabling service" 9 | systemctl enable rss_fetcher.timer 10 | systemctl start rss_fetcher.timer 11 | systemctl start rss_fetcher.service 12 | systemctl status rss_fetcher.service 13 | systemctl status rss_fetcher.timer 14 | systemctl list-timers --all 15 | -------------------------------------------------------------------------------- /internal/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | ) 9 | 10 | type Configuration struct { 11 | Timeout int `json:"timeout"` 12 | Mailserver string `json:"mailserver"` 13 | Mailport int `json:"mailport"` 14 | Mailfrom string `json:"mailfrom"` 15 | Mailonerror bool `json:"mailonerror"` 16 | Mailtoerror string `json:"mailtoerror"` 17 | Mailto string `json:"mailto"` 18 | Feeds []ConfigurationFeed `json:"feeds"` 19 | Database string `json:"database"` 20 | GlobalIgnoreWords []string `json:"globalignorewords"` 21 | Test bool 22 | } 23 | 24 | type ConfigurationFeed struct { 25 | Title string `json:"title"` 26 | URL string `json:"url"` 27 | IgnoreWords []string `json:"ignorewords"` 28 | } 29 | 30 | func GetConfig(f string) (*Configuration, error) { 31 | if f == "" { 32 | return nil, fmt.Errorf("please provide a valid config file") 33 | } 34 | 35 | b, err := os.ReadFile(f) // nolint: gosec 36 | if err != nil { 37 | return nil, err 38 | } 39 | reader := bytes.NewReader(b) 40 | 41 | decoder := json.NewDecoder(reader) 42 | decoder.DisallowUnknownFields() 43 | c := Configuration{} 44 | if err = decoder.Decode(&c); err != nil { 45 | return nil, err 46 | } 47 | return &c, nil 48 | } 49 | -------------------------------------------------------------------------------- /internal/config/config_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "path" 5 | "testing" 6 | ) 7 | 8 | func TestGetConfig(t *testing.T) { 9 | c, err := GetConfig(path.Join("..", "..", "testdata", "test.json")) 10 | if err != nil { 11 | t.Fatalf("got error when reading config file: %v", err) 12 | } 13 | if c == nil { 14 | t.Fatal("got a nil config object") 15 | } 16 | } 17 | 18 | func TestGetConfigErrors(t *testing.T) { 19 | _, err := GetConfig("") 20 | if err == nil { 21 | t.Fatal("expected error on empty filename") 22 | } 23 | _, err = GetConfig("this_does_not_exist") 24 | if err == nil { 25 | t.Fatal("expected error on invalid file") 26 | } 27 | } 28 | 29 | func TestGetConfigInvalid(t *testing.T) { 30 | _, err := GetConfig(path.Join("..", "..", "testdata", "invalid.json")) 31 | if err == nil { 32 | t.Fatal("expected error when reading config file but got none") 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /internal/database/database.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/FireFart/rss_fetcher/internal/config" 8 | "github.com/FireFart/rss_fetcher/internal/pb" 9 | log "github.com/sirupsen/logrus" 10 | "google.golang.org/protobuf/proto" 11 | ) 12 | 13 | func NewDatabase() *pb.Rss { 14 | return &pb.Rss{Feeds: make(map[string]int64)} 15 | } 16 | 17 | func ReadDatabase(database string) (*pb.Rss, error) { 18 | log.Debug("Reading database") 19 | // create database if needed 20 | if _, err := os.Stat(database); os.IsNotExist(err) { 21 | return NewDatabase(), nil 22 | } 23 | 24 | b, err := os.ReadFile(database) // nolint: gosec 25 | if err != nil { 26 | return nil, fmt.Errorf("could not read database %s: %v", database, err) 27 | } 28 | 29 | rssMsg := &pb.Rss{} 30 | if err := proto.Unmarshal(b, rssMsg); err != nil { 31 | return nil, fmt.Errorf("could not unmarshal database %s: %v", database, err) 32 | } 33 | return rssMsg, nil 34 | } 35 | 36 | func SaveDatabase(database string, r proto.Message) error { 37 | b, err := proto.Marshal(r) 38 | if err != nil { 39 | return fmt.Errorf("could not marshal database %s: %v", database, err) 40 | } 41 | if err := os.WriteFile(database, b, 0666); err != nil { 42 | return fmt.Errorf("could not write database %s: %v", database, err) 43 | } 44 | return nil 45 | } 46 | 47 | // removes old feeds from database 48 | func CleanupDatabase(r *pb.Rss, c config.Configuration) { 49 | urls := make(map[string]struct{}) 50 | for _, x := range c.Feeds { 51 | urls[x.URL] = struct{}{} 52 | } 53 | 54 | for url := range r.Feeds { 55 | // delete entry if not present in config file 56 | if _, ok := urls[url]; !ok { 57 | log.Debugf("Removing entry %q from database", url) 58 | delete(r.Feeds, url) 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /internal/database/database_test.go: -------------------------------------------------------------------------------- 1 | package database 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "testing" 8 | 9 | "github.com/FireFart/rss_fetcher/internal/config" 10 | ) 11 | 12 | func TestNewDatabase(t *testing.T) { 13 | x := NewDatabase() 14 | if x.Feeds == nil { 15 | t.Fatal("Feed map is nil") 16 | } 17 | } 18 | 19 | func TestReadEmptyDatabase(t *testing.T) { 20 | r, err := ReadDatabase("") 21 | if err != nil { 22 | t.Fatalf("encountered error on reading database: %v", err) 23 | } 24 | if r == nil { 25 | t.Fatal("returned database is nil") 26 | } 27 | } 28 | 29 | func TestReadDatabase(t *testing.T) { 30 | r, err := ReadDatabase(filepath.Join("..", "..", "testdata", "rss.testdb")) 31 | if err != nil { 32 | t.Fatalf("encountered error on reading database: %v", err) 33 | } 34 | if r == nil { 35 | t.Fatal("returned database is nil") 36 | } 37 | } 38 | 39 | func TestReadInvalidDatabase(t *testing.T) { 40 | _, err := ReadDatabase(filepath.Join("..", "..", "testdata", "invalid.testdb")) 41 | if err == nil { 42 | t.Fatal("expected error but none returned") 43 | } 44 | } 45 | 46 | func TestSaveDatabase(t *testing.T) { 47 | d := NewDatabase() 48 | f, err := os.CreateTemp("", "testdb") 49 | if err != nil { 50 | t.Fatalf("could not create temp file: %v", err) 51 | } 52 | err = SaveDatabase(f.Name(), d) 53 | if err != nil { 54 | t.Fatalf("encountered error on writing database: %v", err) 55 | } 56 | } 57 | 58 | func TestCleanupDatase(t *testing.T) { 59 | initialLen := 5 60 | var tt = []struct { 61 | testName string 62 | invalidEntries int 63 | }{ 64 | {"No invalid entries", 0}, 65 | {"1 invalid entry", 1}, 66 | {"3 invalid entries", 3}, 67 | {"300 invalid entries", 300}, 68 | } 69 | for _, x := range tt { 70 | t.Run(x.testName, func(t *testing.T) { 71 | d := NewDatabase() 72 | c := config.Configuration{} 73 | for i := 0; i < initialLen; i++ { 74 | url := fmt.Sprintf("test%d", i) 75 | d.Feeds[url] = int64(i) 76 | x := config.ConfigurationFeed{URL: url} 77 | c.Feeds = append(c.Feeds, x) 78 | } 79 | 80 | // add invalid feeds to database 81 | for i := 0; i < x.invalidEntries; i++ { 82 | url := fmt.Sprintf("invalid%d", i) 83 | d.Feeds[url] = int64(i) 84 | } 85 | 86 | CleanupDatabase(d, c) 87 | 88 | if len(d.Feeds) != initialLen { 89 | t.Fatalf("expected Feeds to have len %d, got %d", initialLen, len(d.Feeds)) 90 | } 91 | 92 | if len(d.Feeds) != len(c.Feeds) { 93 | t.Fatalf("expected Feeds to have same len as config %d, got %d", initialLen, len(d.Feeds)) 94 | } 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /internal/feed/feed.go: -------------------------------------------------------------------------------- 1 | package feed 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/FireFart/rss_fetcher/internal/config" 10 | "github.com/FireFart/rss_fetcher/internal/helper" 11 | "github.com/FireFart/rss_fetcher/internal/mail" 12 | 13 | "github.com/mmcdole/gofeed" 14 | log "github.com/sirupsen/logrus" 15 | ) 16 | 17 | func FetchFeed(url string, timeout int) (*gofeed.Feed, error) { 18 | t := time.Duration(timeout) * time.Second 19 | netTransport := &http.Transport{ 20 | Dial: (&net.Dialer{ 21 | Timeout: t, 22 | }).Dial, 23 | TLSHandshakeTimeout: t, 24 | } 25 | 26 | fp := gofeed.NewParser() 27 | fp.Client = &http.Client{ 28 | Timeout: t, 29 | Transport: netTransport, 30 | } 31 | 32 | return fp.ParseURL(url) 33 | } 34 | 35 | func ProcessFeed(c config.Configuration, feedInput config.ConfigurationFeed, lastUpdate int64) (int64, error) { 36 | retVal := lastUpdate 37 | feed, err := FetchFeed(feedInput.URL, c.Timeout) 38 | if err != nil { 39 | return 0, fmt.Errorf("could not fetch feed %q: %v", feedInput.URL, err) 40 | } 41 | 42 | for _, item := range feed.Items { 43 | log.Debug(item.Title) 44 | 45 | if item.UpdatedParsed == nil && item.PublishedParsed == nil { 46 | log.Warnf("error in item for feed %s - no published or updated date", feedInput.Title) 47 | continue 48 | } 49 | 50 | var entryLastUpdated int64 51 | if item.UpdatedParsed != nil { 52 | entryLastUpdated = item.UpdatedParsed.UnixNano() 53 | } else { 54 | entryLastUpdated = item.PublishedParsed.UnixNano() 55 | } 56 | 57 | if entryLastUpdated > lastUpdate { 58 | retVal = entryLastUpdated 59 | log.Infof("found entry in feed %q: %q - updated: %s, lastupdated: %s", feedInput.Title, item.Title, helper.TimeToString(time.Unix(0, entryLastUpdated)), helper.TimeToString(time.Unix(0, lastUpdate))) 60 | 61 | words := append(c.GlobalIgnoreWords, feedInput.IgnoreWords...) 62 | if shouldFeedBeIgnored(words, item) { 63 | log.Infof("ignoring entry %q in feed %q because of matched ignore word", item.Title, feedInput.Title) 64 | continue 65 | } 66 | 67 | err = mail.SendFeedItem(c, feedInput.Title, item) 68 | if err != nil { 69 | return 0, err 70 | } 71 | } else { 72 | log.Debugf("feed %q: skipping item %q because date is in the past - updated: %s, lastupdated: %s", 73 | feedInput.Title, item.Title, helper.TimeToString(time.Unix(0, entryLastUpdated)), helper.TimeToString(time.Unix(0, lastUpdate))) 74 | } 75 | } 76 | 77 | return retVal, nil 78 | } 79 | 80 | func shouldFeedBeIgnored(ignoreWords []string, feed *gofeed.Item) bool { 81 | if helper.StringMatches(feed.Title, ignoreWords) || 82 | helper.StringMatches(feed.Content, ignoreWords) || 83 | helper.StringMatches(feed.Description, ignoreWords) { 84 | return true 85 | } 86 | 87 | return false 88 | } 89 | -------------------------------------------------------------------------------- /internal/feed/feed_test.go: -------------------------------------------------------------------------------- 1 | package feed 2 | 3 | import ( 4 | "fmt" 5 | "math" 6 | "net/http" 7 | "net/http/httptest" 8 | "os" 9 | "path/filepath" 10 | "testing" 11 | 12 | "github.com/FireFart/rss_fetcher/internal/config" 13 | ) 14 | 15 | func feedServer(t *testing.T, filename string) *httptest.Server { 16 | t.Helper() 17 | fullName := filepath.Join("..", "..", "testdata", filename) 18 | b, err := os.ReadFile(fullName) 19 | if err != nil { 20 | t.Fatalf("could not read file %s: %v", fullName, err) 21 | } 22 | content := string(b) 23 | ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 24 | fmt.Fprint(w, content) 25 | })) 26 | return ts 27 | } 28 | 29 | func TestFetchFeed(t *testing.T) { 30 | ts := feedServer(t, "valid_feed.xml") 31 | defer ts.Close() 32 | _, err := FetchFeed(ts.URL, 10) 33 | if err != nil { 34 | t.Fatalf("got error when fetching feed: %v", err) 35 | } 36 | } 37 | 38 | func TestFetchFeedInvalid(t *testing.T) { 39 | ts := feedServer(t, "invalid_feed.xml") 40 | defer ts.Close() 41 | _, err := FetchFeed(ts.URL, 10) 42 | if err == nil { 43 | t.Fatalf("expected error but got none") 44 | } 45 | } 46 | 47 | func TestProcessFeed(t *testing.T) { 48 | ts := feedServer(t, "valid_feed.xml") 49 | defer ts.Close() 50 | c := config.Configuration{ 51 | Test: true, 52 | Timeout: 1, 53 | } 54 | input := config.ConfigurationFeed{ 55 | Title: "Title", 56 | URL: ts.URL, 57 | } 58 | 59 | // with mail 60 | _, err := ProcessFeed(c, input, 0) 61 | if err != nil { 62 | t.Fatalf("got error when fetching feed: %v", err) 63 | } 64 | 65 | // no mail 66 | _, err = ProcessFeed(c, input, math.MaxInt64) 67 | if err != nil { 68 | t.Fatalf("got error when fetching feed: %v", err) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /internal/helper/helper.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | 7 | "github.com/FireFart/rss_fetcher/internal/config" 8 | "github.com/FireFart/rss_fetcher/internal/mail" 9 | log "github.com/sirupsen/logrus" 10 | ) 11 | 12 | func ProcessError(c config.Configuration, err error) { 13 | if err == nil { 14 | return 15 | } 16 | 17 | log.Error(err) 18 | if c.Mailonerror { 19 | err = mail.SendErrorMessage(c, err) 20 | if err != nil { 21 | log.Errorf("ERROR on sending error mail: %v", err) 22 | } 23 | } 24 | } 25 | 26 | func TimeToString(t time.Time) string { 27 | return t.Local().Format(time.ANSIC) 28 | } 29 | 30 | func StringMatches(s string, words []string) bool { 31 | if words == nil || len(s) == 0 { 32 | return false 33 | } 34 | 35 | for _, w := range words { 36 | if strings.Contains(s, w) { 37 | return true 38 | } 39 | } 40 | return false 41 | } 42 | -------------------------------------------------------------------------------- /internal/mail/mail.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "bytes" 5 | "crypto/tls" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/FireFart/rss_fetcher/internal/config" 10 | "github.com/mmcdole/gofeed" 11 | log "github.com/sirupsen/logrus" 12 | gomail "gopkg.in/gomail.v2" 13 | ) 14 | 15 | func SendEmail(c config.Configuration, m *gomail.Message) error { 16 | log.Debug("sending mail") 17 | if c.Test { 18 | text, err := messageToString(m) 19 | if err != nil { 20 | return fmt.Errorf("could not print mail: %v", err) 21 | } 22 | log.Infof("[MAIL] %s", text) 23 | return nil 24 | } 25 | d := gomail.Dialer{Host: c.Mailserver, Port: c.Mailport} 26 | d.TLSConfig = &tls.Config{InsecureSkipVerify: true} // nolint: gosec 27 | return d.DialAndSend(m) 28 | } 29 | 30 | func SendErrorMessage(c config.Configuration, errorMessage error) error { 31 | log.Debug("sending error mail") 32 | m := gomail.NewMessage() 33 | m.SetHeader("From", c.Mailfrom) 34 | m.SetHeader("To", c.Mailtoerror) 35 | m.SetHeader("Subject", "ERROR in rss_fetcher") 36 | m.SetBody("text/plain", fmt.Sprintf("%v", errorMessage)) 37 | 38 | return SendEmail(c, m) 39 | } 40 | 41 | func SendFeedItem(c config.Configuration, title string, item *gofeed.Item) error { 42 | m := gomail.NewMessage() 43 | m.SetHeader("From", c.Mailfrom) 44 | m.SetHeader("To", c.Mailto) 45 | m.SetHeader("Subject", fmt.Sprintf("[RSS] [%s]: %s", title, item.Title)) 46 | m.SetBody("text/plain", feedToText(item, false)) 47 | m.AddAlternative("text/html", feedToText(item, true)) 48 | 49 | return SendEmail(c, m) 50 | } 51 | 52 | func messageToString(m *gomail.Message) (string, error) { 53 | buf := new(bytes.Buffer) 54 | _, err := m.WriteTo(buf) 55 | if err != nil { 56 | return "", fmt.Errorf("could not convert message to string: %v", err) 57 | } 58 | return buf.String(), nil 59 | } 60 | 61 | func feedToText(item *gofeed.Item, html bool) string { 62 | linebreak := "\n\n" 63 | if html { 64 | linebreak = "\n

\n" 65 | } 66 | var buffer bytes.Buffer 67 | if item.Link != "" { 68 | _, err := buffer.WriteString(fmt.Sprintf("%s%s", item.Link, linebreak)) 69 | if err != nil { 70 | return err.Error() 71 | } 72 | } 73 | if item.Description != "" { 74 | _, err := buffer.WriteString(fmt.Sprintf("%s%s", item.Description, linebreak)) 75 | if err != nil { 76 | return err.Error() 77 | } 78 | } 79 | if item.Content != "" { 80 | _, err := buffer.WriteString(fmt.Sprintf("%s%s", item.Content, linebreak)) 81 | if err != nil { 82 | return err.Error() 83 | } 84 | } 85 | return strings.TrimSuffix(buffer.String(), linebreak) 86 | } 87 | -------------------------------------------------------------------------------- /internal/mail/mail_test.go: -------------------------------------------------------------------------------- 1 | package mail 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | "testing" 7 | 8 | "github.com/FireFart/rss_fetcher/internal/config" 9 | "github.com/mmcdole/gofeed" 10 | 11 | "gopkg.in/gomail.v2" 12 | ) 13 | 14 | func TestSendEmail(t *testing.T) { 15 | config := config.Configuration{Test: true} 16 | m := gomail.NewMessage() 17 | err := SendEmail(config, m) 18 | if err != nil { 19 | t.Fatalf("error returned: %v", err) 20 | } 21 | } 22 | 23 | func TestSendErrorMessage(t *testing.T) { 24 | config := config.Configuration{ 25 | Test: true, 26 | Mailfrom: "from@mail.com", 27 | Mailonerror: true, 28 | Mailtoerror: "to@mail.com", 29 | } 30 | e := errors.New("test") 31 | err := SendErrorMessage(config, e) 32 | if err != nil { 33 | t.Fatalf("error returned: %v", err) 34 | } 35 | } 36 | func TestSendFeedItem(t *testing.T) { 37 | config := config.Configuration{ 38 | Test: true, 39 | Mailfrom: "from@mail.com", 40 | Mailonerror: true, 41 | Mailtoerror: "to@mail.com", 42 | } 43 | i := gofeed.Item{} 44 | err := SendFeedItem(config, "Title", &i) 45 | if err != nil { 46 | t.Fatalf("error returned: %v", err) 47 | } 48 | } 49 | 50 | func TestFeedToText(t *testing.T) { 51 | item := gofeed.Item{} 52 | item.Description = "Description" 53 | item.Link = "Link" 54 | item.Content = "Content" 55 | 56 | x := feedToText(&item, false) 57 | if !strings.Contains(x, "Description") { 58 | t.Fatal("missing description in feed text") 59 | } 60 | if !strings.Contains(x, "Link") { 61 | t.Fatal("missing link in feed text") 62 | } 63 | if !strings.Contains(x, "Content") { 64 | t.Fatal("missing content in feed text") 65 | } 66 | 67 | x = feedToText(&item, true) 68 | if !strings.Contains(x, "

") { 69 | t.Fatal("missing html line break in feed text") 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /internal/pb/rss.pb.go: -------------------------------------------------------------------------------- 1 | // Code generated by protoc-gen-go. DO NOT EDIT. 2 | // versions: 3 | // protoc-gen-go v1.36.6 4 | // protoc v6.31.1 5 | // source: rss.proto 6 | 7 | package pb 8 | 9 | import ( 10 | protoreflect "google.golang.org/protobuf/reflect/protoreflect" 11 | protoimpl "google.golang.org/protobuf/runtime/protoimpl" 12 | reflect "reflect" 13 | sync "sync" 14 | unsafe "unsafe" 15 | ) 16 | 17 | const ( 18 | // Verify that this generated code is sufficiently up-to-date. 19 | _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) 20 | // Verify that runtime/protoimpl is sufficiently up-to-date. 21 | _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) 22 | ) 23 | 24 | type Rss struct { 25 | state protoimpl.MessageState `protogen:"open.v1"` 26 | LastRun int64 `protobuf:"varint,1,opt,name=LastRun,proto3" json:"LastRun,omitempty"` 27 | Feeds map[string]int64 `protobuf:"bytes,2,rep,name=Feeds,proto3" json:"Feeds,omitempty" protobuf_key:"bytes,1,opt,name=key" protobuf_val:"varint,2,opt,name=value"` 28 | unknownFields protoimpl.UnknownFields 29 | sizeCache protoimpl.SizeCache 30 | } 31 | 32 | func (x *Rss) Reset() { 33 | *x = Rss{} 34 | mi := &file_rss_proto_msgTypes[0] 35 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 36 | ms.StoreMessageInfo(mi) 37 | } 38 | 39 | func (x *Rss) String() string { 40 | return protoimpl.X.MessageStringOf(x) 41 | } 42 | 43 | func (*Rss) ProtoMessage() {} 44 | 45 | func (x *Rss) ProtoReflect() protoreflect.Message { 46 | mi := &file_rss_proto_msgTypes[0] 47 | if x != nil { 48 | ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) 49 | if ms.LoadMessageInfo() == nil { 50 | ms.StoreMessageInfo(mi) 51 | } 52 | return ms 53 | } 54 | return mi.MessageOf(x) 55 | } 56 | 57 | // Deprecated: Use Rss.ProtoReflect.Descriptor instead. 58 | func (*Rss) Descriptor() ([]byte, []int) { 59 | return file_rss_proto_rawDescGZIP(), []int{0} 60 | } 61 | 62 | func (x *Rss) GetLastRun() int64 { 63 | if x != nil { 64 | return x.LastRun 65 | } 66 | return 0 67 | } 68 | 69 | func (x *Rss) GetFeeds() map[string]int64 { 70 | if x != nil { 71 | return x.Feeds 72 | } 73 | return nil 74 | } 75 | 76 | var File_rss_proto protoreflect.FileDescriptor 77 | 78 | const file_rss_proto_rawDesc = "" + 79 | "\n" + 80 | "\trss.proto\x12\x03rss\"\x84\x01\n" + 81 | "\x03Rss\x12\x18\n" + 82 | "\aLastRun\x18\x01 \x01(\x03R\aLastRun\x12)\n" + 83 | "\x05Feeds\x18\x02 \x03(\v2\x13.rss.Rss.FeedsEntryR\x05Feeds\x1a8\n" + 84 | "\n" + 85 | "FeedsEntry\x12\x10\n" + 86 | "\x03key\x18\x01 \x01(\tR\x03key\x12\x14\n" + 87 | "\x05value\x18\x02 \x01(\x03R\x05value:\x028\x01B\rZ\vinternal/pbb\x06proto3" 88 | 89 | var ( 90 | file_rss_proto_rawDescOnce sync.Once 91 | file_rss_proto_rawDescData []byte 92 | ) 93 | 94 | func file_rss_proto_rawDescGZIP() []byte { 95 | file_rss_proto_rawDescOnce.Do(func() { 96 | file_rss_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_rss_proto_rawDesc), len(file_rss_proto_rawDesc))) 97 | }) 98 | return file_rss_proto_rawDescData 99 | } 100 | 101 | var file_rss_proto_msgTypes = make([]protoimpl.MessageInfo, 2) 102 | var file_rss_proto_goTypes = []any{ 103 | (*Rss)(nil), // 0: rss.Rss 104 | nil, // 1: rss.Rss.FeedsEntry 105 | } 106 | var file_rss_proto_depIdxs = []int32{ 107 | 1, // 0: rss.Rss.Feeds:type_name -> rss.Rss.FeedsEntry 108 | 1, // [1:1] is the sub-list for method output_type 109 | 1, // [1:1] is the sub-list for method input_type 110 | 1, // [1:1] is the sub-list for extension type_name 111 | 1, // [1:1] is the sub-list for extension extendee 112 | 0, // [0:1] is the sub-list for field type_name 113 | } 114 | 115 | func init() { file_rss_proto_init() } 116 | func file_rss_proto_init() { 117 | if File_rss_proto != nil { 118 | return 119 | } 120 | type x struct{} 121 | out := protoimpl.TypeBuilder{ 122 | File: protoimpl.DescBuilder{ 123 | GoPackagePath: reflect.TypeOf(x{}).PkgPath(), 124 | RawDescriptor: unsafe.Slice(unsafe.StringData(file_rss_proto_rawDesc), len(file_rss_proto_rawDesc)), 125 | NumEnums: 0, 126 | NumMessages: 2, 127 | NumExtensions: 0, 128 | NumServices: 0, 129 | }, 130 | GoTypes: file_rss_proto_goTypes, 131 | DependencyIndexes: file_rss_proto_depIdxs, 132 | MessageInfos: file_rss_proto_msgTypes, 133 | }.Build() 134 | File_rss_proto = out.File 135 | file_rss_proto_goTypes = nil 136 | file_rss_proto_depIdxs = nil 137 | } 138 | -------------------------------------------------------------------------------- /proto/rss.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package rss; 4 | 5 | option go_package = "internal/pb"; 6 | 7 | message Rss { 8 | int64 LastRun = 1; 9 | map Feeds = 2; 10 | } 11 | -------------------------------------------------------------------------------- /rss_fetcher.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=RSS Fetcher 3 | Wants=network-online.target 4 | After=network.target network-online.target 5 | 6 | [Service] 7 | User=rss 8 | Group=nogroup 9 | SyslogIdentifier=rss 10 | ExecStart=/home/rss/rss_fetcher -config /home/rss/config.json 11 | Restart=no 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /rss_fetcher.timer: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=RSS Fetcher Timer 3 | 4 | [Timer] 5 | OnBootSec=15min 6 | OnCalendar=hourly 7 | Persistent=true 8 | 9 | [Install] 10 | WantedBy=timers.target 11 | -------------------------------------------------------------------------------- /testdata/invalid.json: -------------------------------------------------------------------------------- 1 | I am an invalid JSON file -------------------------------------------------------------------------------- /testdata/invalid.testdb: -------------------------------------------------------------------------------- 1 | Ւ��賥, 2 | Ճ�� -------------------------------------------------------------------------------- /testdata/invalid_feed.xml: -------------------------------------------------------------------------------- 1 | I am an> invalid ", 6 | "mailto": "People ", 7 | "mailonerror": false, 8 | "mailtoerror": "c@c.com", 9 | "database": "rss.db", 10 | "globalignorewords": ["ignore1", "ignore2"], 11 | "feeds": [{ 12 | "title": "test", 13 | "url": "http://feedforall.com/sample.xml", 14 | "ignorewords": ["ignore1", "ignore2"] 15 | }] 16 | } -------------------------------------------------------------------------------- /testdata/valid_feed.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | FeedForAll Sample Feed 5 | RSS is a fascinating technology. The uses for RSS are expanding daily. Take a closer look at how various industries are using the benefits of RSS in their businesses. 6 | http://www.feedforall.com/industry-solutions.htm 7 | Computers/Software/Internet/Site Management/Content Management 8 | Copyright 2004 NotePage, Inc. 9 | http://blogs.law.harvard.edu/tech/rss 10 | en-us 11 | Tue, 19 Oct 2004 13:39:14 -0400 12 | marketing@feedforall.com 13 | Tue, 19 Oct 2004 13:38:55 -0400 14 | webmaster@feedforall.com 15 | FeedForAll Beta1 (0.0.1.8) 16 | 17 | http://www.feedforall.com/ffalogo48x48.gif 18 | FeedForAll Sample Feed 19 | http://www.feedforall.com/industry-solutions.htm 20 | FeedForAll Sample Feed 21 | 48 22 | 48 23 | 24 | 25 | RSS Solutions for Restaurants 26 | <b>FeedForAll </b>helps Restaurant's communicate with customers. Let your customers know the latest specials or events.<br> 27 | <br> 28 | RSS feed uses include:<br> 29 | <i><font color="#FF0000">Daily Specials <br> 30 | Entertainment <br> 31 | Calendar of Events </i></font> 32 | http://www.feedforall.com/restaurant.htm 33 | Computers/Software/Internet/Site Management/Content Management 34 | http://www.feedforall.com/forum 35 | Tue, 19 Oct 2004 11:09:11 -0400 36 | 37 | 38 | RSS Solutions for Schools and Colleges 39 | FeedForAll helps Educational Institutions communicate with students about school wide activities, events, and schedules.<br> 40 | <br> 41 | RSS feed uses include:<br> 42 | <i><font color="#0000FF">Homework Assignments <br> 43 | School Cancellations <br> 44 | Calendar of Events <br> 45 | Sports Scores <br> 46 | Clubs/Organization Meetings <br> 47 | Lunches Menus </i></font> 48 | http://www.feedforall.com/schools.htm 49 | Computers/Software/Internet/Site Management/Content Management 50 | http://www.feedforall.com/forum 51 | Tue, 19 Oct 2004 11:09:09 -0400 52 | 53 | 54 | RSS Solutions for Computer Service Companies 55 | FeedForAll helps Computer Service Companies communicate with clients about cyber security and related issues. <br> 56 | <br> 57 | Uses include:<br> 58 | <i><font color="#0000FF">Cyber Security Alerts <br> 59 | Specials<br> 60 | Job Postings </i></font> 61 | http://www.feedforall.com/computer-service.htm 62 | Computers/Software/Internet/Site Management/Content Management 63 | http://www.feedforall.com/forum 64 | Tue, 19 Oct 2004 11:09:07 -0400 65 | 66 | 67 | RSS Solutions for Governments 68 | FeedForAll helps Governments communicate with the general public about positions on various issues, and keep the community aware of changes in important legislative issues. <b><i><br> 69 | </b></i><br> 70 | RSS uses Include:<br> 71 | <i><font color="#00FF00">Legislative Calendar<br> 72 | Votes<br> 73 | Bulletins</i></font> 74 | http://www.feedforall.com/government.htm 75 | Computers/Software/Internet/Site Management/Content Management 76 | http://www.feedforall.com/forum 77 | Tue, 19 Oct 2004 11:09:05 -0400 78 | 79 | 80 | RSS Solutions for Politicians 81 | FeedForAll helps Politicians communicate with the general public about positions on various issues, and keep the community notified of their schedule. <br> 82 | <br> 83 | Uses Include:<br> 84 | <i><font color="#FF0000">Blogs<br> 85 | Speaking Engagements <br> 86 | Statements<br> 87 | </i></font> 88 | http://www.feedforall.com/politics.htm 89 | Computers/Software/Internet/Site Management/Content Management 90 | http://www.feedforall.com/forum 91 | Tue, 19 Oct 2004 11:09:03 -0400 92 | 93 | 94 | RSS Solutions for Meteorologists 95 | FeedForAll helps Meteorologists communicate with the general public about storm warnings and weather alerts, in specific regions. Using RSS meteorologists are able to quickly disseminate urgent and life threatening weather warnings. <br> 96 | <br> 97 | Uses Include:<br> 98 | <i><font color="#0000FF">Weather Alerts<br> 99 | Plotting Storms<br> 100 | School Cancellations </i></font> 101 | http://www.feedforall.com/weather.htm 102 | Computers/Software/Internet/Site Management/Content Management 103 | http://www.feedforall.com/forum 104 | Tue, 19 Oct 2004 11:09:01 -0400 105 | 106 | 107 | RSS Solutions for Realtors & Real Estate Firms 108 | FeedForAll helps Realtors and Real Estate companies communicate with clients informing them of newly available properties, and open house announcements. RSS helps to reach a targeted audience and spread the word in an inexpensive, professional manner. <font color="#0000FF"><br> 109 | </font><br> 110 | Feeds can be used for:<br> 111 | <i><font color="#FF0000">Open House Dates<br> 112 | New Properties For Sale<br> 113 | Mortgage Rates</i></font> 114 | http://www.feedforall.com/real-estate.htm 115 | Computers/Software/Internet/Site Management/Content Management 116 | http://www.feedforall.com/forum 117 | Tue, 19 Oct 2004 11:08:59 -0400 118 | 119 | 120 | RSS Solutions for Banks / Mortgage Companies 121 | FeedForAll helps <b>Banks, Credit Unions and Mortgage companies</b> communicate with the general public about rate changes in a prompt and professional manner. <br> 122 | <br> 123 | Uses include:<br> 124 | <i><font color="#0000FF">Mortgage Rates<br> 125 | Foreign Exchange Rates <br> 126 | Bank Rates<br> 127 | Specials</i></font> 128 | http://www.feedforall.com/banks.htm 129 | Computers/Software/Internet/Site Management/Content Management 130 | http://www.feedforall.com/forum 131 | Tue, 19 Oct 2004 11:08:57 -0400 132 | 133 | 134 | RSS Solutions for Law Enforcement 135 | <b>FeedForAll</b> helps Law Enforcement Professionals communicate with the general public and other agencies in a prompt and efficient manner. Using RSS police are able to quickly disseminate urgent and life threatening information. <br> 136 | <br> 137 | Uses include:<br> 138 | <i><font color="#0000FF">Amber Alerts<br> 139 | Sex Offender Community Notification <br> 140 | Weather Alerts <br> 141 | Scheduling <br> 142 | Security Alerts <br> 143 | Police Report <br> 144 | Meetings</i></font> 145 | http://www.feedforall.com/law-enforcement.htm 146 | Computers/Software/Internet/Site Management/Content Management 147 | http://www.feedforall.com/forum 148 | Tue, 19 Oct 2004 11:08:56 -0400 149 | 150 | 151 | --------------------------------------------------------------------------------