├── .gitignore ├── screenshot.png ├── .github ├── release.yml ├── workflows │ ├── golangci-lint.yml │ ├── release.yml │ ├── docker-image.yml │ └── codeql-analysis.yml └── dependabot.yml ├── src ├── go.mod ├── go.sum ├── model.go ├── main.go ├── utils.go ├── env.go ├── storage.go ├── routes.go ├── cron.go └── processing.go ├── Dockerfile ├── README.md ├── LICENSE ├── docker-compose.yml └── www ├── custom.css └── index.html /.gitignore: -------------------------------------------------------------------------------- 1 | .idea 2 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/knrdl/miniflux-cleanup/HEAD/screenshot.png -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: New Release 4 | labels: 5 | - "*" 6 | -------------------------------------------------------------------------------- /src/go.mod: -------------------------------------------------------------------------------- 1 | module miniflux-cleanup 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/gorilla/mux v1.8.1 7 | miniflux.app/v2 v2.2.15 8 | ) 9 | -------------------------------------------------------------------------------- /src/go.sum: -------------------------------------------------------------------------------- 1 | github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= 2 | github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= 3 | miniflux.app/v2 v2.2.15 h1:lKFlzDF3QwLBuk/w4btXMIrs2bwjSUkwnGeg3glKjPM= 4 | miniflux.app/v2 v2.2.15/go.mod h1:ewlbgrFlT/RjK3efhFzwjJUCJmmxQJ45Rb9MdN5+DSs= 5 | -------------------------------------------------------------------------------- /.github/workflows/golangci-lint.yml: -------------------------------------------------------------------------------- 1 | name: golangci-lint 2 | on: 3 | push: 4 | branches: 5 | - main 6 | 7 | permissions: 8 | contents: read 9 | 10 | jobs: 11 | golangci: 12 | name: lint 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v6 16 | - uses: actions/setup-go@v6 17 | with: 18 | go-version: stable 19 | - name: golangci-lint 20 | uses: golangci/golangci-lint-action@v9 21 | with: 22 | working-directory: src 23 | 24 | -------------------------------------------------------------------------------- /src/model.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type FilterRule struct { 4 | UrlType string `json:"url_type"` 5 | UrlMode string `json:"url_mode"` 6 | UrlValue string `json:"url_value"` 7 | FilterMode string `json:"filter_mode"` 8 | TitleRegex string `json:"title_regex"` 9 | ContentRegex string `json:"content_regex"` 10 | CategoryRegex string `json:"category_regex"` 11 | State string `json:"state"` 12 | } 13 | 14 | type Config struct { 15 | Rules []FilterRule `json:"rules"` 16 | ApiKey string `json:"api_key"` 17 | } 18 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.25.5-alpine3.22 as builder 2 | 3 | WORKDIR /go/src/app 4 | 5 | COPY src . 6 | 7 | RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /miniflux-cleanup 8 | 9 | 10 | 11 | 12 | 13 | FROM scratch 14 | 15 | EXPOSE 8080/tcp 16 | 17 | VOLUME /data 18 | 19 | COPY www /www 20 | 21 | ADD https://unpkg.com/spectre.css/dist/spectre-icons.min.css /www 22 | ADD https://cdn.jsdelivr.net/npm/@json-editor/json-editor@latest/dist/jsoneditor.min.js /www 23 | ADD https://maxcdn.bootstrapcdn.com/bootstrap/4.5.0/css/bootstrap.min.css /www 24 | 25 | COPY --from=builder /miniflux-cleanup /miniflux-cleanup 26 | 27 | CMD ["/miniflux-cleanup"] 28 | -------------------------------------------------------------------------------- /src/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/gorilla/mux" 5 | "log" 6 | "net/http" 7 | ) 8 | 9 | func main() { 10 | checkEnv() 11 | 12 | startCronjob() 13 | 14 | r := mux.NewRouter() 15 | api := r.PathPrefix("/api/").Subrouter() 16 | api.HandleFunc("/config/preview", handlePreview).Methods("POST") 17 | api.HandleFunc("/config", handleRetrieveConfig).Methods("GET") 18 | api.HandleFunc("/config", handleSaveConfig).Methods("PUT") 19 | 20 | r.PathPrefix("/").Handler(http.FileServer(http.Dir("./www"))) 21 | http.Handle("/", r) 22 | log.Println("Server started on port 8080") 23 | if err := http.ListenAndServe(":8080", nil); err != nil { 24 | log.Fatal(err) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /.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://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "gomod" # See documentation for possible values 9 | directory: "/src" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | - package-ecosystem: "docker" 13 | directory: "/" 14 | schedule: 15 | interval: "weekly" 16 | - package-ecosystem: "github-actions" 17 | directory: "/" 18 | schedule: 19 | interval: "weekly" 20 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Releases 2 | 3 | on: 4 | push: 5 | tags: 6 | - 'v*.*.*' 7 | 8 | jobs: 9 | build: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v6 13 | - id: imagetag 14 | run: | 15 | echo "DOCKER_IMAGE_TAG=$(echo ${{github.ref_name}} | cut -dv -f2)" >> $GITHUB_ENV 16 | - name: Create Release 17 | id: create_release 18 | if: github.event_name != 'pull_request' 19 | uses: actions/create-release@v1 20 | env: 21 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 22 | with: 23 | tag_name: ${{ github.ref_name }} 24 | release_name: ${{ github.ref_name }} 25 | draft: false 26 | prerelease: false 27 | body: | 28 | Docker images: 29 | - ${{ github.repository }}:${{ env.DOCKER_IMAGE_TAG }} 30 | - ghcr.io/${{ github.repository }}:${{ env.DOCKER_IMAGE_TAG }} -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Miniflux Cleanup 2 | 3 | [Miniflux](https://miniflux.app/) is a great RSS Feed Reader, but has limited support for filtering spam out of feeds. 4 | 5 | This tool watches out for new posts and applies the filtering rules to them (see below). 6 | 7 | ## Setup 8 | 9 | ```yaml 10 | version: '3.9' 11 | services: 12 | miniflux-cleanup: 13 | image: knrdl/miniflux-cleanup 14 | restart: always 15 | environment: 16 | MINIFLUX_URL: http://miniflux:8080 # the URL where to find your miniflux instance 17 | AUTH_PROXY_HEADER: 'X-Username' # optional: read username from http header (multi-user mode) 18 | DEFAULT_USERNAME: admin # optional: ... or use this one (single-user mode) 19 | CRONJOB_INTERVAL: 10s # search interval for new posts 20 | ports: 21 | - "8080:8080" 22 | ``` 23 | 24 | ## Screenshot 25 | 26 | No feast for the eyes, but does the job ... 27 | 28 |  29 | 30 | -------------------------------------------------------------------------------- /src/utils.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | ) 8 | 9 | func apiErrorResponse(writer http.ResponseWriter, err error, status int) { 10 | apiTxtResponse(writer, err.Error(), status) 11 | } 12 | 13 | func apiTxtResponse(writer http.ResponseWriter, content string, status int) { 14 | writer.WriteHeader(status) 15 | writer.Header().Set("Content-Type", "text/plain") 16 | if _, err := writer.Write([]byte(content)); err != nil { 17 | fmt.Println(err.Error()) 18 | } 19 | } 20 | 21 | func apiJsonResponse(writer http.ResponseWriter, content interface{}, status int) { 22 | writer.WriteHeader(status) 23 | writer.Header().Set("Content-Type", "application/json") 24 | res, _ := json.Marshal(content) 25 | if _, err := writer.Write(res); err != nil { 26 | fmt.Println(err.Error()) 27 | } 28 | } 29 | 30 | func contains(s []int64, e int64) bool { 31 | for _, a := range s { 32 | if a == e { 33 | return true 34 | } 35 | } 36 | return false 37 | } 38 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 knrdl 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 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | 3 | services: 4 | 5 | postgres: 6 | image: postgres:14-alpine 7 | hostname: postgres 8 | restart: always 9 | environment: 10 | POSTGRES_DB: postgres 11 | POSTGRES_USER: postgres 12 | POSTGRES_PASSWORD: postgres 13 | networks: 14 | net: 15 | 16 | miniflux: 17 | image: miniflux/miniflux 18 | restart: always 19 | environment: 20 | POLLING_FREQUENCY: '60' # minutes 21 | BATCH_SIZE: '100' 22 | CLEANUP_ARCHIVE_READ_DAYS: '7' 23 | AUTH_PROXY_HEADER: 'X-Username' 24 | AUTH_PROXY_USER_CREATION: '1' 25 | PORT: '8080' 26 | DATABASE_URL: postgres://postgres:postgres@postgres/postgres?sslmode=disable 27 | RUN_MIGRATIONS: '1' 28 | CREATE_ADMIN: '1' 29 | ADMIN_USERNAME: admin 30 | ADMIN_PASSWORD: admin123 31 | ports: 32 | - "8080:8080" 33 | networks: 34 | - net 35 | depends_on: 36 | - postgres 37 | 38 | miniflux-cleanup: 39 | build: . 40 | restart: always 41 | environment: 42 | MINIFLUX_URL: http://miniflux:8080 43 | AUTH_PROXY_HEADER: 'X-Username' # read username from http header 44 | DEFAULT_USERNAME: admin # if empty use this one as static username 45 | CRONJOB_INTERVAL: 10s 46 | ports: 47 | - "8081:8080" 48 | networks: 49 | - net 50 | 51 | networks: 52 | net: 53 | -------------------------------------------------------------------------------- /src/env.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "net/http" 7 | "os" 8 | "time" 9 | ) 10 | 11 | const EntryProcessingLimit = 5000 12 | 13 | func hasEnv(varname string) bool { 14 | return len(os.Getenv(varname)) > 0 15 | } 16 | 17 | func getMinifluxUrl() string { 18 | return os.Getenv("MINIFLUX_URL") 19 | } 20 | 21 | func getUsername(request *http.Request) string { 22 | if hasEnv("AUTH_PROXY_HEADER") { 23 | val := request.Header.Get(os.Getenv("AUTH_PROXY_HEADER")) 24 | if len(val) > 0 { 25 | return val 26 | } else { 27 | return os.Getenv("DEFAULT_USERNAME") 28 | } 29 | } else { 30 | return os.Getenv("DEFAULT_USERNAME") 31 | } 32 | } 33 | 34 | func getCronInterval() (time.Duration, error) { 35 | dur, err := time.ParseDuration(os.Getenv("CRONJOB_INTERVAL")) 36 | if err != nil { 37 | return dur, err 38 | } 39 | if dur < 1*time.Second { 40 | return dur, fmt.Errorf("duration of CRONJOB_INTERVAL too short") 41 | } 42 | return dur, nil 43 | } 44 | 45 | func checkEnv() { 46 | if !hasEnv("MINIFLUX_URL") { 47 | log.Fatal("Environment Variable MINIFLUX_URL must be set!") 48 | } 49 | if !hasEnv("CRONJOB_INTERVAL") { 50 | log.Fatal("Environment Variable CRONJOB_INTERVAL must be set!") 51 | } 52 | if _, err := getCronInterval(); err != nil { 53 | log.Fatal(err) 54 | } 55 | if !hasEnv("AUTH_PROXY_HEADER") && !hasEnv("DEFAULT_USERNAME") { 56 | log.Fatal("Either Environment Variable AUTH_PROXY_HEADER or DEFAULT_USERNAME must be set!") 57 | } 58 | 59 | } 60 | -------------------------------------------------------------------------------- /src/storage.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "strings" 11 | ) 12 | 13 | func getConfigUsernames() (files []string, err error) { 14 | fileInfo, err := os.ReadDir("/data") 15 | if err != nil { 16 | return 17 | } 18 | 19 | for _, file := range fileInfo { 20 | if !file.IsDir() && strings.HasSuffix(file.Name(), ".json") { 21 | files = append(files, strings.TrimSuffix(file.Name(), ".json")) 22 | } 23 | } 24 | return 25 | } 26 | 27 | func readConfig(username string, config *Config) error { 28 | configName := filepath.Base(username) + ".json" 29 | configPath := path.Join("/data", configName) 30 | 31 | if _, err := os.Stat(configPath); errors.Is(err, os.ErrNotExist) { 32 | *config = Config{Rules: []FilterRule{}} 33 | return nil 34 | } 35 | byteValue, err := os.ReadFile(configPath) 36 | if err != nil { 37 | return err 38 | } 39 | err = json.Unmarshal(byteValue, &config) 40 | if err != nil { 41 | return err 42 | } 43 | return nil 44 | } 45 | 46 | func writeConfig(username string, config *Config) error { 47 | if filepath.Base(username) == "." { 48 | return fmt.Errorf("empty username not allowed") 49 | } 50 | configPath := path.Join("/data", filepath.Base(username)+".json") 51 | configJson, err := json.Marshal(config) 52 | if err != nil { 53 | return err 54 | } 55 | err = os.WriteFile(configPath, configJson, 0644) 56 | if err != nil { 57 | return err 58 | } 59 | return nil 60 | } 61 | -------------------------------------------------------------------------------- /www/custom.css: -------------------------------------------------------------------------------- 1 | .card { 2 | background-color: initial !important; 3 | border: 1px solid rgba(255, 255, 255, .125) !important; 4 | } 5 | 6 | h3 span.btn-group { 7 | display: none !important; 8 | } 9 | 10 | .form-control { 11 | color: var(--body-color) !important; 12 | background-color: var(--body-background) !important; 13 | } 14 | 15 | pre { 16 | color: var(--body-color) !important; 17 | } 18 | 19 | :root { 20 | --body-color: #efefef; 21 | --body-background: #222; 22 | --table-th-background: #333; 23 | --table-th-color: #aaa; 24 | --input-border: 1px solid #555; 25 | --input-background: #333; 26 | --input-color: #ccc; 27 | --input-focus-color: #efefef; 28 | --input-focus-border-color: rgba(82, 168, 236, 0.8); 29 | --input-focus-box-shadow: 0 0 8px rgba(82, 168, 236, 0.6); 30 | } 31 | 32 | body { 33 | text-rendering: optimizeLegibility; 34 | color: var(--body-color); 35 | background: var(--body-background) 36 | } 37 | 38 | th { 39 | background: var(--table-th-background); 40 | color: var(--table-th-color); 41 | } 42 | 43 | input[type=search], 44 | input[type=url], 45 | input[type=password], 46 | input[type=text], 47 | input[type=number] { 48 | color: var(--input-color); 49 | background: var(--input-background); 50 | border: var(--input-border); 51 | padding: 3px; 52 | line-height: 20px; 53 | font-size: 99%; 54 | -webkit-appearance: none 55 | } 56 | 57 | input[type=search]:focus, 58 | input[type=url]:focus, 59 | input[type=password]:focus, 60 | input[type=text]:focus, 61 | input[type=number]:focus { 62 | color: var(--input-focus-color) !important; 63 | border-color: var(--input-focus-border-color) !important; 64 | outline: 0; 65 | box-shadow: var(--input-focus-box-shadow) 66 | } 67 | 68 | -------------------------------------------------------------------------------- /.github/workflows/docker-image.yml: -------------------------------------------------------------------------------- 1 | name: Docker Image CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | tags: 8 | - 'v[0-9]+.[0-9]+.[0-9]+' 9 | pull_request: 10 | # The branches below must be a subset of the branches above 11 | branches: [ main ] 12 | 13 | jobs: 14 | docker: 15 | runs-on: ubuntu-latest 16 | steps: 17 | - name: Check out the repo 18 | uses: actions/checkout@v6 19 | 20 | - name: Docker meta 21 | id: meta 22 | uses: docker/metadata-action@v5 23 | with: 24 | # list of Docker images to use as base name for tags 25 | images: | 26 | ${{ github.repository }} 27 | ghcr.io/${{ github.repository }} 28 | # generate Docker tags based on the following events/attributes 29 | tags: | 30 | type=semver,pattern={{major}}.{{minor}}.{{patch}} 31 | type=semver,pattern={{major}}.{{minor}} 32 | type=semver,pattern={{major}} 33 | type=edge,branch=main 34 | 35 | - name: Set up QEMU 36 | uses: docker/setup-qemu-action@v3 37 | 38 | - name: Set up Docker Buildx 39 | uses: docker/setup-buildx-action@v3 40 | 41 | - name: Log in to Docker Hub 42 | if: github.event_name != 'pull_request' 43 | uses: docker/login-action@v3 44 | with: 45 | username: ${{ secrets.DOCKER_USERNAME }} 46 | password: ${{ secrets.DOCKER_TOKEN }} 47 | 48 | - name: Login to GHCR 49 | if: github.event_name != 'pull_request' 50 | uses: docker/login-action@v3 51 | with: 52 | registry: ghcr.io 53 | username: ${{ github.repository_owner }} 54 | password: ${{ secrets.GITHUB_TOKEN }} 55 | 56 | - name: Build and push 57 | uses: docker/build-push-action@v6 58 | with: 59 | context: . 60 | platforms: linux/amd64,linux/arm,linux/aarch64 61 | push: ${{ github.event_name != 'pull_request' }} 62 | tags: ${{ steps.meta.outputs.tags }} 63 | labels: ${{ steps.meta.outputs.labels }} 64 | 65 | -------------------------------------------------------------------------------- /.github/workflows/codeql-analysis.yml: -------------------------------------------------------------------------------- 1 | name: "CodeQL" 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | # The branches below must be a subset of the branches above 8 | branches: [ main ] 9 | schedule: 10 | - cron: '18 3 * * 4' 11 | 12 | jobs: 13 | analyze: 14 | name: Analyze 15 | runs-on: ubuntu-latest 16 | permissions: 17 | actions: read 18 | contents: read 19 | security-events: write 20 | 21 | strategy: 22 | fail-fast: false 23 | matrix: 24 | language: [ 'go' ] 25 | # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] 26 | # Learn more about CodeQL language support at https://git.io/codeql-language-support 27 | 28 | steps: 29 | - name: Checkout repository 30 | uses: actions/checkout@v6 31 | 32 | # Initializes the CodeQL tools for scanning. 33 | - name: Initialize CodeQL 34 | uses: github/codeql-action/init@v4 35 | with: 36 | languages: ${{ matrix.language }} 37 | # If you wish to specify custom queries, you can do so here or in a config file. 38 | # By default, queries listed here will override any specified in a config file. 39 | # Prefix the list here with "+" to use these queries and those in the config file. 40 | # queries: ./path/to/local/query, your-org/your-repo/queries@main 41 | 42 | # Autobuild attempts to build any compiled languages (C/C++, C#, or Java). 43 | # If this step fails, then you should remove it and run the build manually (see below) 44 | - name: Autobuild 45 | uses: github/codeql-action/autobuild@v4 46 | 47 | # ℹ️ Command-line programs to run using the OS shell. 48 | # 📚 https://git.io/JvXDl 49 | 50 | # ✏️ If the Autobuild fails above, remove it and uncomment the following three lines 51 | # and modify them (or add more) to build your code if your project 52 | # uses a compiled language 53 | 54 | #- run: | 55 | # make bootstrap 56 | # make release 57 | 58 | - name: Perform CodeQL Analysis 59 | uses: github/codeql-action/analyze@v4 60 | -------------------------------------------------------------------------------- /src/routes.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | 8 | miniflux "miniflux.app/v2/client" 9 | ) 10 | 11 | func handleRetrieveConfig(writer http.ResponseWriter, request *http.Request) { 12 | var payload Config 13 | if err := readConfig(getUsername(request), &payload); err != nil { 14 | apiErrorResponse(writer, err, 500) 15 | } else { 16 | apiJsonResponse(writer, &payload, 200) 17 | } 18 | } 19 | 20 | func handleSaveConfig(writer http.ResponseWriter, request *http.Request) { 21 | decoder := json.NewDecoder(request.Body) 22 | var payload *Config 23 | if err := decoder.Decode(&payload); err != nil { 24 | apiErrorResponse(writer, err, 400) 25 | } else if err := writeConfig(getUsername(request), payload); err != nil { 26 | apiErrorResponse(writer, err, 500) 27 | } else { 28 | requestCronConfigUpdate() 29 | apiTxtResponse(writer, "", 204) 30 | } 31 | } 32 | 33 | func handlePreview(writer http.ResponseWriter, request *http.Request) { 34 | decoder := json.NewDecoder(request.Body) 35 | var payload *Config 36 | if err := decoder.Decode(&payload); err != nil { 37 | apiErrorResponse(writer, err, 400) 38 | } else { 39 | if data, err := queryPreview(payload); err != nil { 40 | apiErrorResponse(writer, err, 500) 41 | } else { 42 | apiTxtResponse(writer, data, 200) 43 | } 44 | } 45 | } 46 | 47 | func queryPreview(payload *Config) (string, error) { 48 | output := "" 49 | client := miniflux.NewClient(getMinifluxUrl(), payload.ApiKey) 50 | var readIds []int64 51 | var removedIds []int64 52 | entryResultSet, err := client.Entries(&miniflux.Filter{Starred: miniflux.FilterNotStarred, Direction: "desc", Status: "unread", Limit: EntryProcessingLimit, Order: "id"}) 53 | if err != nil { 54 | return "", err 55 | } 56 | for _, entry := range entryResultSet.Entries { 57 | for _, rule := range payload.Rules { 58 | if isEntryMatchingRule(entry, &rule) { 59 | if !contains(readIds, entry.ID) && !contains(removedIds, entry.ID) { 60 | switch rule.State { 61 | case "read": 62 | readIds = append(readIds, entry.ID) 63 | case "removed": 64 | removedIds = append(removedIds, entry.ID) 65 | default: 66 | return "", fmt.Errorf("unknown target state: %s", rule.State) 67 | } 68 | output += fmt.Sprintf("[%s] %s (%s)\n", rule.State, entry.Title, entry.Feed.Title) 69 | } 70 | } 71 | } 72 | } 73 | return output, nil 74 | } 75 | -------------------------------------------------------------------------------- /src/cron.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "time" 6 | 7 | miniflux "miniflux.app/v2/client" 8 | ) 9 | 10 | var cronjobUpdate = make(chan bool, 1) 11 | 12 | func requestCronConfigUpdate() { 13 | cronjobUpdate <- true 14 | } 15 | 16 | func startCronjob() { 17 | interval, _ := getCronInterval() 18 | ticker := time.NewTicker(interval) 19 | requestCronConfigUpdate() 20 | go func() { 21 | var usernames []string 22 | configs := make(map[string]Config) 23 | highestIds := make(map[string]int64) 24 | updateConfig := true // init cronjob data on first run 25 | 26 | for { 27 | if updateConfig { // update cronjob data 28 | updateConfig = false 29 | unames, err := getConfigUsernames() 30 | if err != nil { 31 | log.Fatal("get stored user names: ", err) 32 | } 33 | usernames = unames 34 | for _, username := range usernames { 35 | var config Config 36 | if err := readConfig(username, &config); err != nil { 37 | log.Fatal("err reading config:", err) 38 | } 39 | configs[username] = config 40 | highestIds[username] = -1 // reset known ids so all entries will be processed with new rules 41 | } 42 | log.Println("updated cron job") 43 | } 44 | 45 | for _, username := range usernames { // do filter 46 | client := miniflux.NewClient(getMinifluxUrl(), configs[username].ApiKey) 47 | val := getHighestId(client) 48 | if val > highestIds[username] { 49 | if err := runCronUpdate(client, configs[username], highestIds[username]); err != nil { 50 | log.Fatal("cron job failed:", err) 51 | } 52 | highestIds[username] = val 53 | } 54 | } 55 | select { 56 | case <-ticker.C: 57 | case <-cronjobUpdate: 58 | updateConfig = true 59 | } 60 | } 61 | }() 62 | } 63 | 64 | func runCronUpdate(client *miniflux.Client, config Config, oldHighestId int64) error { 65 | var readIds []int64 66 | var removedIds []int64 67 | entryResultSet, err := client.Entries(&miniflux.Filter{Starred: miniflux.FilterNotStarred, Direction: "desc", Status: "unread", AfterEntryID: oldHighestId, Limit: EntryProcessingLimit, Order: "id"}) 68 | if err != nil { 69 | return err 70 | } 71 | for _, entry := range entryResultSet.Entries { 72 | for _, rule := range config.Rules { 73 | if isEntryMatchingRule(entry, &rule) { 74 | if !contains(readIds, entry.ID) && !contains(removedIds, entry.ID) { 75 | switch rule.State { 76 | case "read": 77 | readIds = append(readIds, entry.ID) 78 | case "removed": 79 | removedIds = append(removedIds, entry.ID) 80 | } 81 | } 82 | } 83 | } 84 | } 85 | if err := updateEntries(client, &readIds, &removedIds); err != nil { 86 | return err 87 | } 88 | return nil 89 | } 90 | -------------------------------------------------------------------------------- /src/processing.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "regexp" 6 | "strings" 7 | 8 | miniflux "miniflux.app/v2/client" 9 | ) 10 | 11 | func updateEntries(client *miniflux.Client, readIds *[]int64, removedIds *[]int64) error { 12 | if len(*readIds) > 0 { 13 | if err := client.UpdateEntries(*readIds, "read"); err != nil { 14 | return err 15 | } 16 | } 17 | if len(*removedIds) > 0 { 18 | if err := client.UpdateEntries(*removedIds, "removed"); err != nil { 19 | return err 20 | } 21 | } 22 | return nil 23 | } 24 | 25 | func fmtRegex(regex string) string { 26 | return "(?i)" + strings.ReplaceAll(regex, " ", `\s+`) 27 | } 28 | 29 | func isEntryMatchingRule(entry *miniflux.Entry, rule *FilterRule) bool { 30 | if rule.UrlValue != "" { 31 | var url string 32 | switch rule.UrlType { 33 | case "site": 34 | url = entry.Feed.SiteURL 35 | case "entry": 36 | url = entry.URL 37 | case "feed": 38 | url = entry.Feed.FeedURL 39 | default: 40 | log.Println("unknown url value: ", rule.UrlValue) 41 | return false 42 | } 43 | switch rule.UrlMode { 44 | case "full": 45 | if !strings.EqualFold(rule.UrlValue, url) { 46 | return false 47 | } 48 | case "start": 49 | if !strings.HasPrefix(strings.ToLower(url), strings.ToLower(rule.UrlValue)) { 50 | return false 51 | } 52 | case "regex": 53 | if match, _ := regexp.MatchString(fmtRegex(rule.UrlValue), url); !match { 54 | return false 55 | } 56 | default: 57 | log.Println("unknown url mode: ", rule.UrlMode) 58 | return false 59 | } 60 | } 61 | if rule.TitleRegex != "" { 62 | switch rule.FilterMode { 63 | case "clean": 64 | if match, _ := regexp.MatchString(fmtRegex(rule.TitleRegex), entry.Title); !match { 65 | return false 66 | } 67 | case "keep": 68 | if match, _ := regexp.MatchString(fmtRegex(rule.TitleRegex), entry.Title); match { 69 | return false 70 | } 71 | default: 72 | log.Println("unknown filter mode: ", rule.FilterMode) 73 | return false 74 | } 75 | } 76 | if rule.ContentRegex != "" { 77 | switch rule.FilterMode { 78 | case "clean": 79 | if match, _ := regexp.MatchString(fmtRegex(rule.ContentRegex), entry.Content); !match { 80 | return false 81 | } 82 | case "keep": 83 | if match, _ := regexp.MatchString(fmtRegex(rule.ContentRegex), entry.Content); match { 84 | return false 85 | } 86 | default: 87 | log.Println("unknown filter mode: ", rule.FilterMode) 88 | return false 89 | } 90 | } 91 | if rule.CategoryRegex != "" { 92 | if match, _ := regexp.MatchString(fmtRegex(rule.CategoryRegex), entry.Feed.Category.Title); !match { 93 | return false 94 | } 95 | } 96 | return true 97 | } 98 | 99 | func getHighestId(client *miniflux.Client) int64 { 100 | filter := &miniflux.Filter{Order: "id", Limit: 1, Direction: "desc"} 101 | newestEntry, err := client.Entries(filter) 102 | if err != nil { 103 | log.Panicf("failed to fetch entries: %s", err) 104 | } 105 | if newestEntry.Total == 0 { 106 | return -1 107 | } else { 108 | return newestEntry.Entries[0].ID 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /www/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 |
5 | 6 | 7 |Term Cleanup all entries where title
21 | contains Term^Term Cleanup all entries where title starts
23 | with TermTerm$ Cleanup all entries where title ends
25 | with Term